Spending on food is down 12% this month, mostly from fewer restaurant visits. Groceries held steady at $410.
Copies a value the page shows, and says it did, without moving the reader.
What it copies
A button beside a value the reader pastes somewhere else: a webhook address, a set of setup details, an answer. It copies value, a string your page already holds. value is in the page's HTML and in the server's payload to the browser, so never pass one the page should not hold: a masked account number that copies its whole digits would put the digits in the page.
The default look is a ghost icon button with a tooltip that shows its name. Name it for what it copies, labels={{ copy: 'Copy webhook address' }}, so a list of copy buttons reads as a list of things, not a list of Copies.
Example
Example
https:// hooks.example.com/ ledger/ 7Fq2'use client';
import { CopyButton } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
/** A webhook address the page already shows, which the reader pastes into another service. */
const ADDRESS = 'https://hooks.example.com/ledger/7Fq2';
export default function CopyButtonBasic(): ReactElement {
return (
<div className="row">
{/* A break opportunity after each part, so a phone wraps the address between its words. */}
<code style={{ fontSize: 'var(--text-small)', minWidth: 0 }}>
https://
<wbr />
hooks.example.com/
<wbr />
ledger/
<wbr />
7Fq2
</code>
{/* Named for what it copies: the tip shows the name, and "Copied" for two seconds after a copy. */}
<CopyButton value={ADDRESS} labels={{ copy: 'Copy webhook address' }} />
</div>
);
}Saying it worked
A press copies at once, inside the press, as Safari asks. For two seconds the copy glyph turns to a check, a tip over the button shows "Copied", and a polite status beside the button says it to a screen reader, once for each copy. Then the glyph, the tip and the status go back to rest. Escape, or focus leaving, closes the tip early. Escape closes only the tip, as any tip takes it: in a dialog, the next Escape closes the dialog.
The button's name never changes: a name that changes under focus is read again by some screen readers and not by others. The check is ink, never success's color, since a copy is not a save. A copy that fails keeps the copy glyph and says "Copy failed" in the tip and the status, and onCopy(false) tells your app, which can show the value selected for the reader to copy by hand.
Where the Clipboard API is missing, on a page served over plain http, or refuses, in a frame whose permissions policy blocks it, the part copies with the browser's own copy command instead. Focus stays on the button either way, and a selection the reader had made is put back.
With its word
look="button" draws a secondary button with the glyph and its name, for a copy that needs saying in words, such as several lines of setup details. Its tip appears only after a copy, with the outcome.
Example
Example
'use client';
import { CopyButton } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
/** Two lines a reader pastes into a provider's setup form, in one copy. */
const SETUP = [
'Webhook address: https://hooks.example.com/ledger/7Fq2',
'Events: transactions, balances',
].join('\n');
export default function CopyButtonWord(): ReactElement {
return (
<div className="row">
<CopyButton value={SETUP} look="button" labels={{ copy: 'Copy setup details' }} />
</div>
);
}In a description list
Put it in a row's actions, named "Copy" and the term, as every action in a description list is named. The list stays a static part a server component renders; only the button is a client component.
Example
Example
- Provider
- Northwind Hooks
- Setup address
https://hooks.example.com/ ledger/ 7Fq2
'use client';
import { CopyButton, DescriptionList, DescriptionListItem } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
const ADDRESS = 'https://hooks.example.com/ledger/7Fq2';
export default function CopyButtonDescriptionList(): ReactElement {
return (
<DescriptionList style={{ width: '100%' }}>
<DescriptionListItem term="Provider">Northwind Hooks</DescriptionListItem>
{/* In the row's actions, named "Copy" and the term, as every action in a list is named. */}
<DescriptionListItem
term="Setup address"
actions={<CopyButton value={ADDRESS} labels={{ copy: 'Copy Setup address' }} />}
>
{/* A break opportunity after each part, so a narrow column wraps the address between its words. */}
<code style={{ fontSize: 'var(--text-small)' }}>
https://
<wbr />
hooks.example.com/
<wbr />
ledger/
<wbr />
7Fq2
</code>
</DescriptionListItem>
</DescriptionList>
);
}Under an answer
While an answer streams in, an app marks its body aria-busy="true", and a screen reader may hold back what changes inside a busy part. So put the copy button in the answer's footer, outside the busy part, where its status is always heard. In development, a copy button that speaks inside aria-busy="true" warns once in the console.
Example
Example
'use client';
import { CopyButton, Prose } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
const ANSWER =
'Spending on food is down 12% this month, mostly from fewer restaurant visits. Groceries held steady at $410.';
export default function CopyButtonAnswer(): ReactElement {
// While an answer streams in, the app marks its body aria-busy="true" and sets it false once it has finished.
const isStreaming = false;
return (
<article aria-label="Answer" className="stack" style={{ width: '100%', maxWidth: '36rem' }}>
<Prose aria-busy={isStreaming}>
<p>{ANSWER}</p>
</Prose>
{/* The footer is never busy, so the copy button's status in it is always heard. */}
<footer className="row">
<CopyButton value={ANSWER} labels={{ copy: 'Copy answer' }} />
</footer>
</article>
);
}Usage
A client component: it renders the button and its empty status on the server, and reads nothing from the clipboard or navigator until a press. The words "Copy", "Copied" and "Copy failed" are the library's, in English, so a page in another language passes its own labels.
import { CopyButton } from '@snapshot-ui/react';
<CopyButton
value={webhook.address}
labels={{ copy: 'Copy webhook address' }}
onCopy={(copied) => {
if (!copied) selectAddress();
}}
/>;Props
Every Button prop is accepted but onPress, children, variant, aria-label and the pending state: the press is the copy, and the name is labels.copy. className and style go on the root, span.copy-button, which holds the button and its status, so what places the part moves both; ref and the rest go on the button, so ref.current.focus() focuses it.
className string | Goes on the root |
|---|---|
isDisabled boolean | Whether the button is disabled. |
labels Partial<CopyButtonLabels> | |
look "button" | "icon" |
Default: 'icon' |
onCopy ((copied: boolean) => void) | Called after each copy, with whether it worked. |
style CSSProperties | Goes on the root with |
value string | The text to copy. In the page's HTML and its server payload: never a value the page may not hold. |
copied string | Default: 'Copied' |
|---|---|
copy string | The button's name, unchanged by its outcome. Say what it copies: "Copy answer". Default: 'Copy' |
failed string | Default: 'Copy failed' |
Accessibility
A native button named by labels.copy, which says what it copies, with React Aria handling press across mouse, touch and keyboard. The outcome is said once by a polite status, role="status", that is in the server's HTML and empty, so a screen reader is listening before the words arrive; a second copy is said again. The outcome's tip is for the eye and is never the button's description, so nothing says it twice. Focus stays on the button through the copy and after it.
| Tab | Moves focus to the button. The icon look shows its name in a tip. |
|---|---|
| EnterSpace | Copies; "Copied" is said politely and shown in a tip for two seconds. |
| Escape | Closes the tip, and only the tip: in a dialog, the next Escape closes the dialog. |
Design rules
What keeps copy buttons in the system.
Name it for what it copies, such as Copy answer or Copy Setup address.
Put it beside the value it copies, or in the row that shows it.
Copy only a value the page already shows.
Never copy a masked value's whole digits.
Never show a toast for a copy. The button says it.
Never draw the check in success's color. A copy is not a save.