Composites

Copy Button

A button that puts a string on the clipboard and says so — swapping its icon and label for a couple of seconds, announcing the change to screen readers, and falling back to the legacy copy command on the browsers where the modern clipboard API is unavailable.

Basic usage

value is what lands on the clipboard; label is what the button says. They are deliberately separate — the thing worth copying is almost never a good button label.

The default strings are Vietnamese

Every display string this package ships defaults to Vietnamese. CopyButton is one of the small composites that takes plain string props rather than a labels object, so there is no partial override to lean on: an English UI passes both label and copiedLabel, and — for icon-only buttons — aria-label as well. The left button below is the untouched default.

Icon only

label={null} drops the text span and leaves the icon. Note the trap: the component computes its accessible name as label ?? (copied ? copiedLabel : 'Sao chép'), so a null label falls through to a hard-coded Vietnamese string that neither label nor copiedLabel can reach. The rest props are spread after the internal aria-label, which is the escape hatch — pass your own and it wins.

It is a Button

CopyButtonProps extends ButtonProps, so every variant, size and native button attribute is available. Two defaults are re-declared: variant='ghost' and size='sm' — a copy control is a secondary action sitting next to the thing it copies, not the primary action of the screen.

Two inherited props that do not work here

  • asChild — Radix's Slot accepts exactly one child, and CopyButton always renders three (icon, label span, live region). It throws.
  • children and the native onCopy event are removed from the type on purpose — the first because the button owns its content, the second because it reads exactly like onCopied and is a different thing entirely.

How long “Copied” is held

resetAfterMs controls the confirmation window. Clicking again while it is open clears the pending timer and starts a new one, and the timer is cleared on unmount — a copy button in a row that gets filtered away will not call setState on a dead component. Try all three below.

Callbacks

onCopied receives the copied string, which makes it easy to log an analytics event or reveal a follow-up step. It fires only after the write succeeded — the copied state and the callback are driven by the same branch.

Nothing copied yet.

Two ways to write, and one silent failure

The component tries navigator.clipboard.writeText() first. That API is gated on a secure context, so it is simply absent on a plain-http page that is not localhost, and on older in-app WebViews. When it is missing, the component builds an off-screen <textarea>, selects it, and calls the deprecated document.execCommand('copy').

The two paths are chosen by feature detection, not chained as a retry: the fallback runs only when writeText is absent. A writeText that exists and then rejects — a denied permission, a document that is not focused — goes straight to onCopyError without the textarea ever being tried.

The fallback exists because the failure mode without it is the worst kind: the button does nothing, shows nothing, and the user pastes whatever was on the clipboard before. Whichever path failed, the error is routed to onCopyError — and that is the only signal. The idle icon and label stay exactly as they were, so an app that leaves onCopyError unhandled reproduces the same silent failure the fallback was written to prevent.

tsx
import { CopyButton, toast } from '@comitor/ui'

export function ShareLink({ shareUrl }: { shareUrl: string }) {
  return (
    <CopyButton
      value={shareUrl}
      label="Copy link"
      copiedLabel="Copied"
      /* Nothing changes visually when the write fails — the icon stays a clipboard and
         no live region fires. Surface it yourself or the user will paste stale text. */
      onCopyError={() =>
        toast('Could not reach the clipboard', {
          variant: 'destructive',
          description: 'Select the link and copy it by hand.',
        })
      }
    />
  )
}

In context

The common shape: a value the user needs to move somewhere else, with the control pinned to the end of the row.

sk_live_9f2c41a8de
https://comitor.app/i/8f3a-21bd

Props

PropTypeDefaultDescription
valuerequiredstringThe exact text written to the clipboard. Not the button label — the label is a separate prop.
labelstring | null'Sao chép'Visible label before a copy. Pass null for an icon-only button; pass an English string for an English UI.
copiedLabelstring'Đã chép'Visible label while the copied state is held, and the text announced by the built-in live region.
resetAfterMsnumber2000How long the copied state is held before the button returns to its idle label and icon. The timer restarts on every successful copy and is cleared on unmount.
onCopied(value: string) => voidFired after a successful write, with the same string that was copied.
onCopyError(error: unknown) => voidFired when the write fails — either navigator.clipboard.writeText rejected, or it was unavailable and the execCommand fallback returned false. The button shows nothing on failure, so wire this up.
variant'default' | 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link''ghost'Inherited from Button, but the default is overridden — Button's own default is 'default'.
size'default' | 'md' | 'sm' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg''sm'Inherited from Button with an overridden default. Use an icon-* size together with label={null}.
aria-labelstringForwarded through the rest spread, which is applied last — so it overrides the accessible name the component computes from label / copiedLabel. Required for icon-only buttons in a non-Vietnamese UI.
classNamestringMerged after the variant classes and after the copied-state class via tailwind-merge, so anything you pass here wins.
...restOmit<ButtonProps, 'children' | 'onCopy'>Every other Button prop — disabled, loading, leftIcon, rightIcon, type, form, native handlers. children is removed because the button builds its own content; onCopy (the native clipboard event) is removed so it cannot be confused with onCopied.

Why the copied label is text-success-ink

The confirmation is text on the page background, which is the -ink role in the three-role colour contract, not the background-fill role. The source records the measurement behind it: the fill token --success used as a text colour is 3.13:1 at light, under the 4.5:1 AA needs; --success-ink is 6.52:1 at light and 10.85:1 at dark. Because the ink token flips with the theme, no dark: override is needed.

One consequence worth knowing: cn() is tailwind-merge, and the copied class is applied after the variant classes — so on a filled variant (default, primary, destructive) the green ink replaces the variant's foreground and lands on a solid gold or red fill rather than on the page background it was measured against. Green ink on the gold fill falls to roughly 3.5:1. The ghost default, and the outline / secondary / link variants, all keep the label on the page background where the token is correct. If you do need a filled copy button, pass your own copied colour through className.

Accessibility

  • Renders a real <button type="button"> — focusable, activated by Enter and Space, and safe inside a form because the explicit type stops it submitting.
  • The icon carries aria-hidden="true"; it is decoration beside the label, never the accessible name.
  • The accessible name never changes. The component always sets aria-label, and with a string label that name stays on the idle wording even while the visible text reads “Copied”. That is deliberate: a button whose name mutates under the cursor is hard to refer to by voice control.
  • Success is announced instead, by an aria-live="polite" sr-only span that is always in the DOM and empty until a copy succeeds. Rendering the region up front is what makes the later change announce at all — a live region inserted at the same moment as its text is usually missed by assistive technology.
  • The confirmation is not colour-only: the icon swaps from a clipboard to a check and the label text changes, so the state survives greyscale and colour-blind viewing (WCAG 1.4.1).
  • Colour comes from text-success-ink — 6.52:1 at light, 10.85:1 at dark against the page background. See the section above for why the fill token would not do.
  • The focus ring is Button's: focus-visible:outline-2 in outline-ring with a 2px offset — an outline, not a box-shadow, so it survives forced-colours mode.
  • A failed copy is invisible and silent. Handle onCopyError and raise a toast, otherwise the user gets no perceivable feedback at all and believes the copy worked.
  • For icon-only buttons in a non-Vietnamese UI, pass aria-label. Without it the accessible name is the built-in “Sao chép”, which will not match the surrounding language and will be read with the wrong pronunciation rules.