Skip to content
design
Components

Copy button

A button that puts a value on the clipboard and says it did, labeled or as an icon alone.

On this page
MMSI235 123 456
Call signMABC7
Position50° 08.52′ N 005° 03.10′ W
The identifiers the crew read out or type into a form, each with an icon-only copy beside it.
Code
import { CopyButton } from "@bosunkit/components/copy-button";
import type { CSSProperties, ReactElement } from "react";

export const description: string =
  "The identifiers the crew read out on the radio or type into a form, each with an icon-only copy beside it: the " +
  "MMSI and call sign are copied as written, and the position as degrees and decimal minutes.";

const ROW: CSSProperties = {
  display: "flex",
  alignItems: "center",
  justifyContent: "space-between",
  gap: "var(--space-md)",
  borderBottom: "1px solid var(--border)",
};

const TERM: CSSProperties = { color: "var(--text-dim)", fontFamily: "var(--font-mono)" };

export default function Showcase(): ReactElement {
  const identifiers: readonly (readonly [string, string, string])[] = [
    ["MMSI", "235 123 456", "235123456"],
    ["Call sign", "MABC7", "MABC7"],
    ["Position", "50° 08.52′ N 005° 03.10′ W", "50° 08.52′ N 005° 03.10′ W"],
  ];
  return (
    <div style={{ width: "100%", maxWidth: 440, display: "grid" }}>
      {identifiers.map(([term, shown, value]: readonly [string, string, string]): ReactElement => (
        <div key={term} style={ROW}>
          <span style={TERM}>{term}</span>
          <span style={{ flex: 1, textAlign: "end", fontFamily: "var(--font-mono)" }}>{shown}</span>
          <CopyButton
            variant="ghost"
            iconOnly
            value={value}
            label={`Copy ${term}`}
            copiedLabel="Copied"
            failedLabel="Could not copy"
          />
        </div>
      ))}
    </div>
  );
}

A copy button writes value to the clipboard when it is pressed and shows the result: the copy mark swaps for a check with a short scale and fade, and the label changes with it ("Copy position", "Position copied"). If the browser refuses, which it does when the page has no permission or no secure connection, the mark swaps for a cross and the button says so. After resultDurationMs the button returns to its label.

The three labels sit in one cell, so the button is as wide as the longest and a result never moves what is beside it. A label changing in place is not announced, so the result is also written to a polite status line. The caller supplies all three labels, translated, so the component adds no words of its own.

01Say that it workedCopying changes nothing on the screen, so the icon and the label both change, and a screen reader hears the result.
02Move nothing beside itThe button keeps the width of its longest label; a row of values does not shuffle when one is copied.
03Name what is copiedAn icon-only button is named by its label ("Copy MMSI") in a tooltip and to a screen reader, and keeps the name after a copy.

Examples

Labeled

The labeled button, where a copy is the action of its own: the icon swaps and the label changes in place, and the button keeps the width of its longest label.
Code
import { CopyButton } from "@bosunkit/components/copy-button";
import type { ReactElement } from "react";

export const description: string =
  "The labeled button, where a copy is the action of its own: the icon swaps and the label changes in place, and the " +
  "button keeps the width of its longest label.";

export default function Labeled(): ReactElement {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: "var(--space-md)" }}>
      <CopyButton
        value="50° 08.52′ N 005° 03.10′ W"
        label="Copy position"
        copiedLabel="Position copied"
        failedLabel="Could not copy"
      />
      <CopyButton
        variant="primary"
        value="Engine hours 1,842"
        label="Copy for the log"
        copiedLabel="Copied"
        failedLabel="Could not copy"
      />
    </div>
  );
}

Icon only

The icon-only button is a 48 px square named by its `label`, in a tooltip and to a screen reader. The name stays the same after a copy; the result is announced on its own line.
Code
import { CopyButton } from "@bosunkit/components/copy-button";
import type { ReactElement } from "react";

export const description: string =
  "The icon-only button is a 48 px square named by its `label`, in a tooltip and to a screen reader. The name stays " +
  "the same after a copy; the result is announced on its own line.";

export default function IconOnly(): ReactElement {
  return (
    <div style={{ display: "flex", alignItems: "center", gap: "var(--space-md)" }}>
      <CopyButton iconOnly value="235123456" label="Copy MMSI" copiedLabel="Copied" failedLabel="Could not copy" />
      <CopyButton
        iconOnly
        variant="ghost"
        value="235123456"
        label="Copy MMSI"
        copiedLabel="Copied"
        failedLabel="Could not copy"
      />
    </div>
  );
}

Copy the useful representation

Choose whether the copied value is a human-readable display value, a raw identifier or complete source text, and name the action accordingly. Preserve YAML indentation and authored line breaks when copying code. A report copy action should include the context needed to interpret the content rather than copying only a colored badge.

When the clipboard is unavailable, keep the original text selectable or provide a source/download route. The failed label should explain that copying failed without implying the underlying record changed. Check keyboard activation and the announced result, including repeated copies of the same value.

When the copy is refused

A page without clipboard permission refuses the copy. The button then says so, and the text appears beside it in a read-only field, selected and focused, with the keys that copy it (⌘C on Apple's platforms, Ctrl+C elsewhere), so the crew can copy it by hand. The field stays until it loses focus.

Props

PropTypeDefaultDescription
variantButtonVariant | undefined–The button's emphasis: primary for the main action, danger for a destructive one, ghost for a quiet one.
textCaseButtonTextCase | undefined–How the label is cased: in capitals by default. none keeps the letters as written, for a label whose case carries meaning, such as a chess move in SAN (Nf3, exd5), a call sign or a chart number.
valuerequiredstring–The text a press puts on the clipboard: an MMSI, a call sign, a position, a part number.
labelrequiredstring–What the button says, and its name for a screen reader when it is icon-only ("Copy MMSI"); translated by the caller.
copiedLabelrequiredstring–What it says once the text is on the clipboard ("Copied"); translated by the caller.
failedLabelrequiredstring–What it says when the browser refuses the copy ("Could not copy"); translated by the caller.
iconOnlyboolean | undefinedfalseWhether the button is the icon alone, a square touch target that names itself in a tooltip and to a screen reader. Off by default, where the icon sits beside the label.
resultDurationMsnumber | undefined2000How long the result ("Copied", "Could not copy") stays before the button returns to its label, in milliseconds.
onResult((status: "copied" | "failed") => void) | undefined–Called with the result of each press, after the button shows it.
motionboolean | undefinedtrueWhether the icon swaps with a short scale and fade and the label fades to the result; on by default. false shows each at once. Under reduced motion the icon only fades, whatever this says.
testIdstring | undefined–Becomes data-testid on the button; the result line is ${testId}--status.
refRef<HTMLButtonElement> | undefined–The button element.

Also takes every attribute of Omit<ButtonProps, "children" | "onClick" | "size" | "motion" | "ref">.

Do
Name the value in the label, so each icon in a list says what it copies.
Don't
Don't label every icon plain 'Copy': beside several values, a screen reader hears the same name for each.
  • Button: the button a labeled copy is built on, with its emphases and sizes.
  • Tooltip: the name an icon-only copy button shows.
  • Description list: the terms and values a copy button usually sits beside.