Copy to clipboard
A value someone needs to move somewhere else — an API key, an install command, an order reference — with a button that puts it on the clipboard. The value stays selectable and readable; the button is a shortcut, not the only way in.
When to use it
Where the value is long, exact, and going to be pasted: a token, a connection string, a shell command. Anything a reader would otherwise select by hand and probably clip a character off.
Not for values people read rather than transfer. A price or a date gains nothing from a copy button, and every button on a page costs the reader a moment deciding whether it is for them.
The class is the look; the attribute is the behaviour
This is the one thing to get right. deck-extras.js binds to
[data-deck-copy] — the attribute. .copy-btn is styling and
nothing else, so a button with the class and no attribute looks exactly right and does
nothing at all.
sk_live_EXAMPLE_NOT_A_REAL_KEY
<div class="copy" style="max-inline-size:30rem">
<code>sk_live_EXAMPLE_NOT_A_REAL_KEY</code>
<button type="button" class="copy-btn" data-deck-copy aria-label="Copy API key">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#copy"></use></svg>
<span>Copy</span>
</button>
</div>
<div class="copy" style="max-inline-size:30rem">
<code>sk_live_EXAMPLE_NOT_A_REAL_KEY</code>
<button type="button" class="copy-btn" data-deck-copy aria-label="Copy API key">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#copy"></use></svg>
<span>Copy</span>
</button>
</div>
With no value, the attribute means "look for the value yourself": the script takes the
text of the <code> or .copy-value inside the surrounding
.copy, and failing that the button's previous sibling. Give the attribute a
selector and it copies that element instead — its value if it has one, its
text if not.
<div class="stack stack-3" style="max-inline-size:30rem">
<textarea class="textarea" id="dx-copy-src" rows="2">npm install @echodial/deck</textarea>
<button type="button" class="btn" data-deck-copy="#dx-copy-src">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#copy"></use></svg>
Copy the command
</button>
</div>
<div class="stack stack-3" style="max-inline-size:30rem">
<textarea class="textarea" id="dx-copy-src" rows="2">npm install @echodial/deck</textarea>
<button type="button" class="btn" data-deck-copy="#dx-copy-src">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#copy"></use></svg>
Copy the command
</button>
</div>
The same function is exposed as Deck.copy(text), which returns a promise
resolving to true or false, for cases where the text does not
exist on the page at all.
Two kinds of feedback, and how to choose
After a successful copy the button gets .is-copied for 1.8 seconds. What
that shows depends on what is inside the button, and the switch is easy to miss:
-
Put a
.copy-doneelement inside the button and it swaps places with.copy-idle— the label changes in place, and no toast is raised. - Leave it out and the script raises a toast instead: "Copied", for two seconds.
-
.copy-inlinenever toasts, whether or not it has a.copy-done. It is meant for a table cell, where a toast per row would be unbearable.
<div class="copy" style="max-inline-size:30rem">
<span class="copy-value">https://deck.example/invite/9fQ2xR</span>
<button type="button" class="copy-btn" data-deck-copy aria-label="Copy invite link">
<span class="copy-idle">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#copy"></use></svg>
</span>
<span class="copy-done">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#check"></use></svg>
</span>
</button>
</div>
<div class="copy" style="max-inline-size:30rem">
<span class="copy-value">https://deck.example/invite/9fQ2xR</span>
<button type="button" class="copy-btn" data-deck-copy aria-label="Copy invite link">
<span class="copy-idle">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#copy"></use></svg>
</span>
<span class="copy-done">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#check"></use></svg>
</span>
</button>
</div>
.copy-done is display: none until .is-copied
appears, at which point .copy-idle hides and it becomes
inline-flex. Both live inside the button, so the button's width can jump if
the two labels are different lengths — give them the same content width, or use icons of
the same size as above.
The inline variant
.copy-inline is a faint icon-only button for use beside a value already in
the flow — an order number in a table, an ID in a
list. It has no row, no border and no toast.
| Order | Reference |
|---|---|
| Tuesday | ORD-4417-QK |
| Wednesday | ORD-4418-RM |
<table class="table" style="max-inline-size:30rem">
<thead><tr><th>Order</th><th>Reference</th></tr></thead>
<tbody>
<tr><td>Tuesday</td><td><span id="dx-ref-1">ORD-4417-QK</span> <button type="button" class="copy-inline" data-deck-copy="#dx-ref-1" aria-label="Copy reference ORD-4417-QK"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#copy"></use></svg></button></td></tr>
<tr><td>Wednesday</td><td><span id="dx-ref-2">ORD-4418-RM</span> <button type="button" class="copy-inline" data-deck-copy="#dx-ref-2" aria-label="Copy reference ORD-4418-RM"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#copy"></use></svg></button></td></tr>
</tbody>
</table>
<table class="table" style="max-inline-size:30rem">
<thead><tr><th>Order</th><th>Reference</th></tr></thead>
<tbody>
<tr><td>Tuesday</td><td><span id="dx-ref-1">ORD-4417-QK</span> <button type="button" class="copy-inline" data-deck-copy="#dx-ref-1" aria-label="Copy reference ORD-4417-QK"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#copy"></use></svg></button></td></tr>
<tr><td>Wednesday</td><td><span id="dx-ref-2">ORD-4418-RM</span> <button type="button" class="copy-inline" data-deck-copy="#dx-ref-2" aria-label="Copy reference ORD-4418-RM"><svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#copy"></use></svg></button></td></tr>
</tbody>
</table>
Classes
Generated from src/23-inputs.css by tools/docs/extract.mjs.
| Class | Layer | What it does | Source |
|---|---|---|---|
.copy |
deck.components |
The behaviour hangs off [data-deck-copy], not off .copy-btn. The class is the look and nothing else, so a button with the class and no attribute looks exactly right and does nothing at all -- the single most common way to get this component wrong. Whether a successful copy raises a toast depends on what is inside the button: a .copy-done element swaps in place and no toast fires; without one, deck-extras.js toasts instead. .copy-inline never toasts. | 23-inputs.css:358 |
.copy-btn |
deck.components |
Sets flex, display, align-items, gap, and 9 more. | 23-inputs.css:386 |
.copy-done |
deck.components |
No declarations. | 23-inputs.css:403 |
.copy-idle |
deck.components |
No declarations. | 23-inputs.css:405 |
.copy-inline |
deck.components |
Inline icon-only variant, for an order number or an API key in a table | 23-inputs.css:408 |
.copy-value |
deck.components |
No declarations. | 23-inputs.css:367 |
.is-copied |
deck.components |
No declarations. | 23-inputs.css:402 |
Tokens
| Token | Live | Declared value |
|---|---|---|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--surface-2 |
light-dark(var(--ink-25), var(--ink-900)) |
|
--surface-hover |
light-dark(var(--ink-50), var(--ink-900)) |
|
--good-100 |
oklch(94% calc(var(--chroma-status) * .3) var(--hue-good)) |
|
--good-700 |
oklch(44% var(--chroma-status) var(--hue-good)) |
|
--font-mono |
Ag 0123 | ui-monospace, "SF Mono", "Cascadia Mono", "Roboto Mono", Menlo, Consolas, monospace |
--text-sm |
.8125rem |
|
--r-sm |
7px |
|
--r-xs |
4px |
|
--tap |
44px |
|
--dur-1 |
110ms |
Accessibility
- The button needs a label that names the value. "Copy" is fine when there is one on the page and useless when there are six. In a table, put the value in the label — "Copy reference ORD-4417-QK" — as the example does.
-
The first copy is announced to nobody. The script sets
aria-live="polite"on the button after it has already swapped the label. A live region has to exist before the change for the change to be announced, so the first press is silent; every press after that announces, because the attribute is by then already there. Inconsistent rather than absent, which is harder to notice. Recorded inFINDINGS.md. -
The toast is the more reliable feedback. Where there is no
.copy-done, Deck raises a toast, and the toast region is a properly-declared live region. If it matters that the reader knows the copy worked, prefer that path. -
The value must stay selectable. It is real text in a
<code>or a<span>, so anyone can select it by hand. Do not replace it with an image or a disabled input — the button is an accelerator and the manual route has to keep working. -
The row scrolls, and the scrollbar is hidden.
scrollbar-width: noneplus a hidden WebKit scrollbar means a long value can be scrolled horizontally with no visible indication that there is more. A keyboard user cannot reach that overflow at all unless the element is focusable. This is the same gap recorded for Deck's other scroll containers. -
Failure is silent. If the clipboard write fails,
.is-copiedis not added and nothing is said. The reader presses the button and nothing happens, which they will read as a broken page rather than a blocked permission.
Secure contexts and the fallback
navigator.clipboard only exists in a secure context — HTTPS, or
localhost. On a plain HTTP origin it is undefined, and the script falls back
to creating an off-screen <textarea>, selecting it and calling
document.execCommand('copy').
That fallback works today in every engine and is
deprecated in all of them. It is a bridge for old browsers and
non-secure origins, not a strategy — serve the page over HTTPS and the modern path is
the one that runs. Deck already records the deprecation in
FINDINGS.md, since the editor depends on
execCommand far more heavily than this does.
Right to left
The row mirrors — the button moves to the left — but the value does not. The value cell
carries direction: ltr and unicode-bidi: isolate, for the same
reason as the phone field: a key, a URL or a command is a
sequence, not a sentence, and reversing it would be wrong. The isolate stops
surrounding Arabic or Hebrew text from re-ordering the punctuation inside it.
sk_live_EXAMPLE_NOT_A_REAL_KEY
<div dir="rtl" class="copy" style="max-inline-size:30rem">
<code>sk_live_EXAMPLE_NOT_A_REAL_KEY</code>
<button type="button" class="copy-btn" data-deck-copy aria-label="نسخ المفتاح">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#copy"></use></svg>
<span>نسخ</span>
</button>
</div>
<div dir="rtl" class="copy" style="max-inline-size:30rem">
<code>sk_live_EXAMPLE_NOT_A_REAL_KEY</code>
<button type="button" class="copy-btn" data-deck-copy aria-label="نسخ المفتاح">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#copy"></use></svg>
<span>نسخ</span>
</button>
</div>
Reduced motion
The button transitions background-color and color over
110ms, and the global reset collapses it.
The idle-to-done swap is display, which has never been animated here, so
the tick appears instantly in every setting. If a toast is raised instead, its motion is
governed by the toast.
Printing
.copy is not in src/99-print.css, and it needs attention if
your page prints. The value itself prints correctly — real text in a mono face — but the
button prints with it, and a long value that was scrolled horizontally on screen prints
truncated at the visible width, because white-space: nowrap
and overflow-x still apply on paper.
@media print {
.copy-btn, .copy-inline { display: none; }
.copy { border: 0; background: none; }
/* the important half: let the value wrap instead of being cut off */
.copy > code, .copy > .copy-value { white-space: pre-wrap; overflow: visible; }
}
An API key silently cut in half on a printed runbook is worse than no key at all.
Overriding it
@layer app.components {
/* Let a long value wrap on screen rather than scroll */
.copy > code { white-space: pre-wrap; overflow: visible; }
/* Bring the hidden scrollbar back */
.copy > :is(code, .copy-value) { scrollbar-width: thin; }
.copy > :is(code, .copy-value)::-webkit-scrollbar { display: block; block-size: 6px; }
/* Stop the button jumping between its two labels */
.copy-btn { min-inline-size: 7rem; justify-content: center; }
}
When not to use it
- Not for a value people read. A total, a date, a name. The button implies the value is going somewhere else.
-
Not for a secret you should not be showing. A copy button on a
password field displays the password to everyone in the room. If it must not be read,
it must not be on screen — copy it from a store, with
Deck.copy(), without rendering it. - Not as the only way to get the value. Clipboard writes can be blocked by permissions, by the browser, or by a non-secure origin, and the failure is silent. The text has to remain selectable.
- Not on every row of a long table. Fifty copy buttons is fifty tab stops. Consider one button that copies the column.
-
Not with
.copy-btnalone. Withoutdata-deck-copyit is a button that looks like it works and does not — see the top of this page.