Skip to content

Copy button

An icon button that copies a value and confirms with a check.

Status
Stable
Category
Actions
Adoption
Not used yet
import { CopyButton } from "@oration/canon/components/copy-button";
packages/canon/src/components/copy-button.tsx

Signing secret

Verify that remittance webhooks came from Cedarline. Keep it on your server.

whsec_cedarline_7Hq2Lw9xRk4Vt1Pz
import { CopyButton } from "@oration/canon/components/copy-button";import { toast } from "@oration/canon/components/toast";export function Hero() {    const secret = "whsec_cedarline_7Hq2Lw9xRk4Vt1Pz";    return (        <div className="flex w-full max-w-md flex-col gap-3 rounded-xl bg-card p-4 text-left shadow-border">            <div className="flex flex-col gap-0.5">                <p className="text-sm font-medium">Signing secret</p>                <p className="text-13 text-pretty text-muted-foreground">                    Verify that remittance webhooks came from Cedarline. Keep it                    on your server.                </p>            </div>            <div className="flex items-center gap-1 rounded-[10px] bg-muted/70 py-1 pr-1 pl-2.5">                <code className="min-w-0 flex-1 truncate font-mono text-xs">                    {secret}                </code>                <CopyButton                    value={secret}                    label="Copy signing secret"                    onCopied={() =>                        toast.add({                            title: "Signing secret copied",                            description:                                "Paste it into your webhook handler's config.",                        })                    }                />            </div>        </div>    );}

Usage#

Copy button is a small icon button that writes a value to the clipboard and confirms with a green check for 1.6 seconds. It sits beside machine strings people paste elsewhere: signing secrets, workspace and payment IDs, forwarding addresses, join codes. It is one of the most used components in settings. The common mistake is leaving label at its default Copy, so a page of copy buttons all announce the same name.

When to use

  • Beside a secret, key or token that people paste into another system.
  • Beside a record or run ID in a header or table row, at icon-xs.
  • Beside a generated address or code, such as an email forwarding address or a team join code.
  • Inside a tint well with the value, when you need a layout CopyValue doesn't give you.

When not to use

  • For a labelled read-only value in a well with its copy button. The ready-made row does it in one element. Use Copy row
  • For a block of code with its own copy control. Use Code block
  • For any other icon action, such as download or open. Use Icon action
  • For sharing a link with people, where a labelled Copy link button is clearer. Use Button

The Machine Mono Rule

What it copies is usually a machine string: show that value beside it in Geist Mono, so people can check what they're about to paste.

Icon-only buttons carry a name

Set label to name the value: Copy signing secret, Copy INV-20418. The default Copy is only acceptable when it is the only one in view.

Anatomy#

  1. Button. A square ghost Button, 28px by default, with Slate Meta icons that turn ink on hover.
  2. Icon. A 16px copy icon that cross-fades to a Ledger Green check on copy, with a 0.3s spring that scales and blurs between them.

Examples#

Sizes

24, 28 and 32px. The default icon-sm sits in a tint well; icon-xs sits inline beside an ID in a row or a line of text.

icon-xs
icon-sm
icon
import { CopyButton } from "@oration/canon/components/copy-button";export function Sizes() {    const sizes = ["icon-xs", "icon-sm", "icon"] as const;    return (        <div className="flex items-end gap-6">            {sizes.map((size) => (                <div key={size} className="flex flex-col items-center gap-2">                    <CopyButton                        value="INV-20418"                        label="Copy invoice number"                        size={size}                        variant="outline"                    />                    <span className="font-mono text-xs text-muted-foreground">                        {size}                    </span>                </div>            ))}        </div>    );}

Variants

Ghost by default, so it stays quiet beside the value. Outline when it stands alone on a busy surface.

ghost
outline
import { CopyButton } from "@oration/canon/components/copy-button";export function Variants() {    return (        <div className="flex items-end gap-6">            <div className="flex flex-col items-center gap-2">                <CopyButton value="PAY-88004" label="Copy payment ID" />                <span className="font-mono text-xs text-muted-foreground">                    ghost                </span>            </div>            <div className="flex flex-col items-center gap-2">                <CopyButton                    value="PAY-88004"                    label="Copy payment ID"                    variant="outline"                />                <span className="font-mono text-xs text-muted-foreground">                    outline                </span>            </div>        </div>    );}

Beside an ID

An extra-small copy button right after a machine ID in a header. onCopied adds a toast, which screen readers announce.

Cedarline AP

Workspace IDws_8f3k2m9q

import { CopyButton } from "@oration/canon/components/copy-button";import { toast } from "@oration/canon/components/toast";export function InlineId() {    return (        <div className="flex w-full max-w-md items-center justify-between gap-3 rounded-xl bg-card px-4 py-3 text-left shadow-border">            <div className="min-w-0">                <p className="text-sm font-medium">Cedarline AP</p>                <p className="flex items-center gap-1 text-13 text-muted-foreground">                    Workspace ID                    <code className="font-mono text-xs text-foreground">                        ws_8f3k2m9q                    </code>                    <CopyButton                        value="ws_8f3k2m9q"                        label="Copy workspace ID"                        size="icon-xs"                        onCopied={() =>                            toast.add({ title: "Workspace ID copied" })                        }                    />                </p>            </div>        </div>    );}

In a table cell

One per row, each labelled with the value it copies so the buttons have distinct names.

InvoiceSupplierAmount
INV-20418Northwind Freight$18,240.00
INV-20411Halcyon$4,120.50
INV-20407Orchard Street$960.00
import { CopyButton } from "@oration/canon/components/copy-button";export function TableCell() {    const rows = [        {            id: "INV-20418",            supplier: "Northwind Freight",            amount: "$18,240.00",        },        { id: "INV-20411", supplier: "Halcyon", amount: "$4,120.50" },        { id: "INV-20407", supplier: "Orchard Street", amount: "$960.00" },    ];    return (        <div className="w-full max-w-md overflow-hidden rounded-xl bg-card text-left shadow-border">            <table className="w-full text-13">                <thead>                    <tr className="border-b border-border text-xs text-muted-foreground">                        <th                            scope="col"                            className="h-8 px-3 text-left font-medium"                        >                            Invoice                        </th>                        <th                            scope="col"                            className="h-8 px-3 text-left font-medium"                        >                            Supplier                        </th>                        <th                            scope="col"                            className="h-8 px-3 text-right font-medium"                        >                            Amount                        </th>                    </tr>                </thead>                <tbody>                    {rows.map((row) => (                        <tr                            key={row.id}                            className="border-b border-border last:border-0"                        >                            <td className="h-9 px-3">                                <span className="flex items-center gap-1">                                    <code className="font-mono text-xs">                                        {row.id}                                    </code>                                    <CopyButton                                        value={row.id}                                        label={`Copy ${row.id}`}                                        size="icon-xs"                                    />                                </span>                            </td>                            <td className="h-9 px-3">{row.supplier}</td>                            <td className="h-9 px-3 text-right tabular-nums">                                {row.amount}                            </td>                        </tr>                    ))}                </tbody>            </table>        </div>    );}

States#

Rest
Hover
Focus
import { CopyButton } from "@oration/canon/components/copy-button";export function StatesRow() {    const states = [        { name: "Rest", className: "" },        { name: "Hover", className: "bg-muted text-foreground" },        { name: "Focus", className: "border-ring ring-3 ring-ring/40" },    ];    return (        <div className="flex w-full flex-wrap justify-center gap-10" inert>            {states.map((state) => (                <div                    key={state.name}                    className="flex flex-col items-center gap-2"                >                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                    <CopyButton                        value="INV-20418"                        label="Copy invoice number"                        className={state.className}                    />                </div>            ))}        </div>    );}
States
StateTreatment
RestTransparent with a Slate Meta copy icon. Outline adds the hairline and control shadow.
HoverWell Gray fill and an ink icon over 150ms.
Focus visibleIndigo border and a 3px Focus Indigo ring at 40%.
PressedScales to 0.96 while held, when motion is allowed.
CopiedThe icon swaps to a green check and the name changes to Copied for 1.6 seconds, then swaps back.

Behavior#

  • On click it calls navigator.clipboard.writeText(value), shows the check and calls onCopied.
  • The check resets after 1.6 seconds. Clicking again restarts it.
  • Clipboard errors are swallowed, and the check shows either way; see Known gaps.
  • The icon swap uses Icon swap: both icons share one grid cell, so the button never shifts. With reduced motion, the app's motion config drops the scale and keeps the fade.
  • It renders type="button", so it's safe inside forms.

Do and don't#

PAY-88004
Do. Name each copy button after the value it copies.
PAY-88004INV-20418
Don't. Leave label at its default. Every button on the page is announced as Copy.

Content#

  • label is Copy plus the value's name: Copy signing secret, Copy forwarding address, Copy join code.
  • In repeated rows, name the instance: Copy INV-20418.
  • When you add a toast through onCopied, title it with what was copied and, if useful, where it goes next: Signing secret copied, Paste it into your webhook handler's config.

Accessibility#

  • The name is label, and switches to Copied while the check shows. Name changes aren't reliably announced, so add a toast through onCopied when confirmation matters.
  • There is no tooltip. Keep the value visible beside it so sighted people know what the button copies.
  • Icons inside are decorative; the name comes from aria-label.
  • icon-xs is 24px, the minimum target size. Use icon-sm on touch layouts.
Keyboard interactions
KeysAction
TabMoves focus to the button.
EnterCopies the value.
SpaceCopies the value.

Design tokens#

Design tokens
TokenUsed for
--muted-foregroundCopy icon at rest
--foregroundIcon on hover
--mutedHover fill
--successThe check
--ringFocus border and 3px ring at 40%

API reference#

CopyButton

An icon Button that copies value.

Props of CopyButton
PropTypeDefaultDescription
valueRequiredstringNo defaultThe text written to the clipboard.
labelstring"Copy"The accessible name. Name the value.
size"icon-xs" | "icon-sm" | "icon""icon-sm"24, 28 or 32px.
variant"ghost" | "outline""ghost"Button variant.
onCopied() => voidNo defaultCalled after each click, for a toast or analytics.
classNamestringNo defaultMerged onto the button.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

It shows the check and calls onCopied even when the clipboard write fails or navigator.clipboard is missing, as on plain HTTP, so people can be told it copied when it didn't.

There's no tooltip, unlike other icon-only buttons in the system.

The Copied confirmation is only an aria-label change, with no live region.