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

https://hooks.example.com/ledger/7Fq2
Follows the page theme and corners
'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

Follows the page theme and corners
'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

Provider
Northwind Hooks
Setup address
https://hooks.example.com/ledger/7Fq2
Follows the page theme and corners
'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

Spending on food is down 12% this month, mostly from fewer restaurant visits. Groceries held steady at $410.

Follows the page theme and corners
'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 span.copy-button, which holds the button and its status.

isDisabled
boolean

Whether the button is disabled.

labels
Partial<CopyButtonLabels>

look
"button" | "icon"

icon is a ghost IconButton with a Tooltip; button a secondary Button with the icon and the word.

Default: 'icon'

onCopy
((copied: boolean) => void)

Called after each copy, with whether it worked.

style
CSSProperties

Goes on the root with className, so a style that places the part moves the button and its status.

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.

TabMoves focus to the button. The icon look shows its name in a tip.
EnterSpaceCopies; "Copied" is said politely and shown in a tip for two seconds.
EscapeCloses 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.

esc

↑ ↓ to move↵ to open