Copy button
An icon button that copies a value and confirms with a check.
Signing secret
Verify that remittance webhooks came from Cedarline. Keep it on your server.
whsec_cedarline_7Hq2Lw9xRk4Vt1Pzimport { 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
CopyValuedoesn'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
Icon-only buttons carry a name
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#
- Button. A square ghost Button, 28px by default, with Slate Meta icons that turn ink on hover.
- 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.
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.
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.
| Invoice | Supplier | Amount |
|---|---|---|
INV-20418 | Northwind Freight | $18,240.00 |
INV-20411 | Halcyon | $4,120.50 |
INV-20407 | Orchard 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#
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> );}| State | Treatment |
|---|---|
| Rest | Transparent with a Slate Meta copy icon. Outline adds the hairline and control shadow. |
| Hover | Well Gray fill and an ink icon over 150ms. |
| Focus visible | Indigo border and a 3px Focus Indigo ring at 40%. |
| Pressed | Scales to 0.96 while held, when motion is allowed. |
| Copied | The 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 callsonCopied. - 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-88004PAY-88004INV-20418label at its default. Every button on the page is announced as Copy.Content#
labelis 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 throughonCopiedwhen 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-xsis 24px, the minimum target size. Useicon-smon touch layouts.
| Keys | Action |
|---|---|
| Tab | Moves focus to the button. |
| Enter | Copies the value. |
| Space | Copies the value. |
Design tokens#
| Token | Used for |
|---|---|
--muted-foreground | Copy icon at rest |
--foreground | Icon on hover |
--muted | Hover fill |
--success | The check |
--ring | Focus border and 3px ring at 40% |
API reference#
CopyButton
An icon Button that copies value.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | string | No default | The text written to the clipboard. |
label | string | "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 | () => void | No default | Called after each click, for a toast or analytics. |
className | string | No default | Merged 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.