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.

A bare data-deck-copy takes the value from the .copy row it sits in
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.

Any button anywhere can copy any element — .copy is not required
<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-done element 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-inline never toasts, whether or not it has a .copy-done. It is meant for a table cell, where a toast per row would be unbearable.
A .copy-done inside the button means the tick replaces the icon and no toast fires
https://deck.example/invite/9fQ2xR
<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.

Each label names the value, so the two buttons are distinguishable in a list of links
OrderReference
TuesdayORD-4417-QK
WednesdayORD-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.

Classes, generated from the stylesheet source
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

Design tokens this component reads
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 in FINDINGS.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: none plus 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-copied is 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.

Button on the left, key still reading left to right
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-btn alone. Without data-deck-copy it is a button that looks like it works and does not — see the top of this page.