Buttons

Copy Button

A ghost icon or label button that writes text to the clipboard and flashes a timed "Copied" confirmation.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Copy } from "lucide-react"
import { cn } from "@/lib/utils"

/** Small pop when the icon swaps between Copy and Check. */
const KEYFRAMES = `@keyframes cb-pop{from{transform:scale(0.5);opacity:0}to{transform:scale(1);opacity:1}}`

export interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  /** Text written to the clipboard when the button is clicked. */
  text: string
  /** Delay (ms) before the "copied" state falls back to idle. */
  resetDelay?: number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/copy-button.json

Prompt

Build a React + TypeScript + Tailwind "CopyButton" component (lucide-react icons,
a cn() utility for className merging, no other dependencies).

Contract
- Export a forwardRef button component extending React.ButtonHTMLAttributes<HTMLButtonElement>.
- Props: `text: string` (the content written to the clipboard), `resetDelay?: number`
  (default 2000ms), `variant?: "icon" | "label"` (default "icon" — "icon" renders an
  icon-only square button, "label" renders an icon plus a "Copy" / "Copied" word),
  and `onCopied?: () => void` fired once after a successful write.

Behavior
- On click, guard for `navigator.clipboard.writeText` existing (SSR, insecure origin,
  unsupported browser). If missing, do nothing and stay idle — never throw.
- On a successful write: flip to a "copied" state (swap the Copy icon for a Check
  icon; in the "label" variant, swap the word "Copy" for "Copied" in sync), call
  `onCopied`, and start a timer that reverts to idle after `resetDelay`.
- Clicking again while already copied clears and restarts the timer instead of
  stacking multiple timers.
- On a rejected write (permission denied, clipboard blocked): stay idle silently,
  no error is thrown or surfaced.
- The reset timer is cleared on unmount so no `setState` fires after the button
  is gone. Every state update happens inside the clipboard promise's resolve
  callback or the timer callback — never synchronously during render or in a
  bare mount effect.
- The "copied" transition is announced to assistive tech via a visually-hidden
  `aria-live="polite"` region ("Copied"), independent of the visible icon/label swap.
- The "icon" variant carries `aria-label="Copy to clipboard"` on the button itself
  (there is no visible text to serve as the accessible name); the "label" variant
  doesn't need one — its visible text already names the button.

Rendering & styling
- Ghost styling: `border bg-transparent hover:bg-muted rounded-md`; the "icon"
  variant is a `size-8` square. All colors are semantic tokens — no hex/oklch.
  Merge the consumer's className via `cn()`.
- When copied, the Check icon is tinted with `var(--chart-2)` via inline style
  (not a hardcoded green).
- The icon swap has a small scale-in pop (`prefers-reduced-motion` disables it —
  the icon still swaps instantly, only the transition is removed).
- Focus-visible ring + `disabled:pointer-events-none disabled:opacity-50` like
  any other button.

Customization levers
- `resetDelay` — how long the "copied" state holds before falling back to idle.
- `variant` — swap "icon" for "label" when the button needs to read as an
  explicit "Copy" action rather than a bare icon (e.g. next to a code block vs.
  inside a dense toolbar).
- Colors/icon — swap the `var(--chart-2)` copied tint for another chart token,
  or swap the Check/Copy icon pair for a different lucide pair.
- Feedback — wire `onCopied` to a toast (`sonner`) if the button needs a more
  prominent confirmation than the icon swap + `aria-live` announcement.
- Relationship to `use-copy-to-clipboard`: this button is the batteries-included,
  drop-in version — pick it when a ghost icon/label button is exactly what you
  need. Reach for the `use-copy-to-clipboard` hook instead when the UI around
  the copy action doesn't look like a button at all (e.g. a highlighted row, a
  custom toast, a non-button trigger) and you need to build that UI yourself.

Concepts

  • Icon-swap feedback — the Copy icon is replaced by a Check icon on success instead of adding a separate confirmation element, so the button itself is the feedback surface.
  • Timed state with restart-on-repeat — the "copied" state is timer-backed, not a one-shot flag; clicking again while already copied restarts the timer instead of letting it flicker back to idle mid-read.
  • aria-live announcement decoupled from visuals — a visually-hidden aria-live="polite" region announces "Copied" so screen-reader users get the same feedback sighted users get from the icon swap, without duplicating the visible label.
  • Fail silent, stay idle — a rejected or unavailable clipboard write never throws or shows an error state; the button simply behaves as if nothing happened, so a denied permission can't strand the UI in a broken state.
  • Button vs. hook — this component owns the full button UI (ghost styling, icon, label); use-copy-to-clipboard owns only the copied/error state machine for building a differently-shaped UI around the same copy action.

On This Page