View as Markdown

Clipboard

A Stimulus behavior that copies text to the clipboard — either a value you give it or the content of an element — with optional copied feedback.

100%
Loading...
<div data-controller="clipboard" class="">
    <code data-clipboard-target="source" class="">composer require symfony/ux-toolkit</code>
    <span class="">
        <button type="button" data-action="clipboard#copy" class="">Copy</button>
        <span data-clipboard-target="success" class="">Copied!</span>
    </span>
</div>

Installation

php bin/console ux:install clipboard --kit common

Copy the following file(s) into your app:

assets/controllers/clipboard_controller.js
import { Controller } from '@hotwired/stimulus';

/**
 * @value      source           Text to copy to the clipboard; when set, it takes precedence over the `source` target.
 * @value      successDuration  How long, in milliseconds, the success feedback stays active after a copy. Defaults to 2000.
 * @target     source           Element to copy from when no `source` value is set: its form value (input, textarea, select) or, failing that, its text content.
 * @target     success          Element revealed briefly after a successful copy, then hidden again. Hidden on connect.
 * @target     idle             Element hidden while the `success` target is showing, then revealed again; pair it with `success` inside the button to swap the label.
 * @css-class  success          Class(es) added to the controller element for the same window, as a CSS-only alternative to the `success`/`idle` targets.
 * @action     copy             Copies the source to the clipboard, then dispatches a `clipboard:copied` event and flashes the success feedback.
 */
export default class extends Controller {
    static targets = ['source', 'success', 'idle'];
    static classes = ['success'];
    static values = {
        source: String,
        successDuration: { type: Number, default: 2000 },
    };

    #timeout;

    connect() {
        if (this.hasSuccessTarget) {
            this.successTarget.hidden = true;
        }
    }

    async copy(event) {
        event.preventDefault();

        const text = this.#text;
        await navigator.clipboard.writeText(text);

        this.dispatch('copied', { detail: { text } });
        this.#flashSuccess();
    }

    get #text() {
        if (this.hasSourceValue) {
            return this.sourceValue;
        }

        // Form controls expose their content as `value`; everything else falls back to text content.
        return this.sourceTarget.value ?? this.sourceTarget.textContent;
    }

    #flashSuccess() {
        if (!this.hasSuccessTarget && !this.hasSuccessClass) {
            return;
        }

        clearTimeout(this.#timeout);
        this.#toggleSuccess(true);
        this.#timeout = setTimeout(() => this.#toggleSuccess(false), this.successDurationValue);
    }

    #toggleSuccess(active) {
        if (this.hasSuccessTarget) {
            this.successTarget.hidden = !active;
        }

        if (this.hasIdleTarget) {
            this.idleTarget.hidden = active;
        }

        this.successClasses.forEach((className) => this.element.classList.toggle(className, active));
    }
}

Usage

Add data-controller="clipboard", mark the element to copy from with a source target, then trigger clipboard#copy from a button:

<div data-controller="clipboard">
    <code data-clipboard-target="source">Text to copy</code>
    <button type="button" data-action="clipboard#copy">Copy</button>
</div>

To copy a fixed string instead of an element, set a source value (data-clipboard-source-value) — see Copy using a Value.

Note

Every successful copy also dispatches a clipboard:copied event, with the copied text available as event.detail.text. Listen for it to drive your own feedback.

Examples

Copy a Form Element's Value

Form controls — inputs, textareas, selects — are copied from their value:

100%
Loading...
<div data-controller="clipboard" class="">
    <input type="text" data-clipboard-target="source" value="hello@example.com" readonly class="">
    <button type="button" data-action="clipboard#copy" class="">Copy email</button>
</div>

Copy an Element's Text

For non-form elements, the source target's text content is copied:

100%
Loading...
<div data-controller="clipboard" class="">
    <p data-clipboard-target="source" class="">The quick brown fox jumps over the lazy dog.</p>
    <button type="button" data-action="clipboard#copy" class="">Copy text</button>
</div>

Copy Code

A classic: a code block with its own copy button. Point the source target at the <pre> — its text content is copied verbatim, indentation and newlines included:

100%
Loading...
<div data-controller="clipboard" class="">
    <pre data-clipboard-target="source" class=""><code>$response = new JsonResponse([
    'status' => 'ok',
]);</code></pre>
    <button type="button" data-action="clipboard#copy" class="">
        <span data-clipboard-target="idle">Copy</span>
        <span data-clipboard-target="success" hidden>Copied!</span>
    </button>
</div>

Copy using a Value

Set a source value to copy a fixed string, independent of what's shown on screen. Here the field displays a masked key while the button copies the real one:

100%
Loading...
<div data-controller="clipboard" data-clipboard-source-value="a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" class="">
    <input type="text" value="xxxxxxxxxxxxxxxxxxxx" readonly class="">
    <button type="button" data-action="clipboard#copy" class="">Copy API Key</button>
</div>

Copied Feedback

Add a success target to reveal a confirmation after a successful copy. It's hidden again after successDuration milliseconds (defaults to 2000):

100%
Loading...
<div data-controller="clipboard" data-clipboard-success-duration-value="1500" class="">
    <code data-clipboard-target="source" class="">symfony serve -d</code>
    <span class="">
        <button type="button" data-action="clipboard#copy" class="">Copy</button>
        <span data-clipboard-target="success" class="">Copied to clipboard!</span>
    </span>
</div>

Swap the Button Label

Put both an idle and a success label inside the button: the idle one is hidden while the success one shows, swapping "Copy" for "Copied!" on click.

100%
Loading...
<div data-controller="clipboard" class="">
    <code data-clipboard-target="source" class="">git clone git@github.com:symfony/ux.git</code>
    <button type="button" data-action="clipboard#copy" class="">
        <span data-clipboard-target="idle">Copy</span>
        <span data-clipboard-target="success" hidden>Copied!</span>
    </button>
</div>

Animate the Feedback

The success target is toggled from display: none to visible on copy, and showing a hidden element restarts its CSS animations — so an animated success target replays its effect on every click. Give it a burst @keyframes and match successDuration to the animation's length:

100%
Loading...
<style>
@keyframes clipboard-burst {
    from { transform: scale(.75); opacity: .75; }
    to { transform: scale(2) rotate(20deg); opacity: 0; }
}
</style>
<div data-controller="clipboard" data-clipboard-success-duration-value="450" class="">
    <code data-clipboard-target="source" class="">php bin/console cache:clear</code>
    <button type="button" data-action="clipboard#copy" class="">
        Copy
        <span data-clipboard-target="success" class="" style="animation: clipboard-burst 450ms linear forwards"></span>
    </button>
</div>

Feedback via a CSS Class

Prefer to drive feedback purely from CSS? Instead of success/idle targets, set a success class with data-clipboard-success-class. The controller adds it to its element for successDuration, so you can restyle anything beneath it — including the trigger itself:

Tip

Pass multiple space-separated classes if you like (data-clipboard-success-class="ring pulse") — they're all applied for the duration.

100%
Loading...
<style>.clipboard-copied .clipboard-btn { background-color: #16a34a; }</style>
<div data-controller="clipboard" data-clipboard-success-class="clipboard-copied" class="">
    <code data-clipboard-target="source" class="">php bin/console about</code>
    <button type="button" data-action="clipboard#copy" class="">Copy</button>
</div>

Feedback with a Tooltip

Pair with the tooltip recipe for a floating confirmation. With both controllers on the button, copying dispatches clipboard:copied right there, opening a manual, self-hiding tooltip above it — no success target or class needed:

100%
Loading...
<style>
    .tooltip { position: absolute; top: 0; left: 0; width: max-content; border-radius: .25rem; background: #4c1d95; color: #fff; padding: .25rem .5rem; font-size: .75rem; font-weight: 500; pointer-events: none; z-index: 50; }
    .tooltip-arrow { position: absolute; width: 8px; height: 8px; background: inherit; transform: rotate(45deg); }
</style>
<div class="">
    <div class="">
        <code class="">php bin/console debug:router</code>
        <button type="button" data-controller="clipboard tooltip" data-clipboard-source-value="php bin/console debug:router" data-action="clipboard#copy clipboard:copied->tooltip#show" data-tooltip-content-value="Copied!" data-tooltip-trigger-value="manual" data-tooltip-auto-hide-value="2000" class="">Copy</button>
    </div>
</div>

API Reference

data-controller="clipboard"

Value Type Default
data-clipboard-source-value 
String -
data-clipboard-success-duration-value 
Number 2000
Target Description
source Element to copy from when no source value is set: its form value (input, textarea, select) or, failing that, its text content.
success Element revealed briefly after a successful copy, then hidden again. Hidden on connect.
idle Element hidden while the success target is showing, then revealed again; pair it with success inside the button to swap the label.
Class Description
data-clipboard-success-class Class(es) added to the controller element for the same window, as a CSS-only alternative to the success/idle targets.
Action Description
copy Copies the source to the clipboard, then dispatches a clipboard:copied event and flashes the success feedback.