Something new is coming.Join the waitlist

Copy Button

PreviousNext

Minimal copy-to-clipboard button. The icon morphs from copy to check on success and resets after a configurable delay. Built with motion.

$ pnpm dlx shadcn@latest add copy-button

Installation

npx shadcn@latest add https://ui.srb.codes/r/copy-button.json

Usage

Pass the string you want to write to the clipboard via value. The button handles the rest — on click it calls navigator.clipboard.writeText, swaps the icon to a checkmark, and resets back after the delay window elapses.

"use client";
 
import { CopyButton } from "@/components/copy-button";
 
export function ShareLink() {
  const url = "https://example.com/shared-content";
 
  return (
    <div className="flex items-center gap-2">
      <input
        readOnly
        value={url}
        className="flex-1 rounded-md border px-3 py-2 text-sm"
      />
      <CopyButton value={url} />
    </div>
  );
}

The component is a motion.button under the hood, so it forwards every standard button attribute (disabled, aria-*, onFocus, …) plus motion-specific props like whileHover and transition if you want to tweak the press animation.

Patterns

Reacting to a successful copy

Use the onCopy callback to trigger side effects — toast notifications, analytics, optimistic UI — without losing the built-in icon feedback.

"use client";
 
import { toast } from "sonner";
 
import { CopyButton } from "@/components/copy-button";
 
export function ApiKeyRow({ apiKey }: { apiKey: string }) {
  return (
    <div className="flex items-center gap-2 font-mono text-sm">
      <code className="flex-1 truncate rounded bg-muted px-2 py-1">
        {apiKey}
      </code>
      <CopyButton
        value={apiKey}
        onCopy={() => toast.success("API key copied")}
      />
    </div>
  );
}

The callback only fires when the clipboard write succeeds. If the browser denies permission or the page isn't on HTTPS, onCopy is silently skipped — the icon also stays in its copy state, so users can see the click didn't take effect.

Tightening or relaxing the reset delay

The default 2 s window is comfortable for share-link and code-snippet patterns. For dense surfaces — a list of API keys, a data table cell — drop it to ~1.2 s so users can copy several rows in quick succession without waiting for the icon to reset. For high-stakes confirmations (a one-time recovery token), bump it up to 4–5 s so the success state stays visible long enough to register.

<CopyButton value={value} delay={1200} />   // dense lists
<CopyButton value={value} delay={5000} />   // critical confirmations

Driving the press animation

CopyButton re-exports the motion.button API, so the whileTap and whileHover props compose normally. The default is a scale: 0.9 press; override it for a softer, larger, or rotation-based effect.

<CopyButton
  value={value}
  whileTap={{ scale: 0.95, rotate: -4 }}
  transition={{ type: "spring", stiffness: 400, damping: 18 }}
/>

Props

PropTypeDefaultDescription
valuestringText content written to the clipboard on click. Required.
delaynumber2000Milliseconds the success (check) state stays visible before resetting back to copy.
onCopy(value: string) => voidFired after the clipboard write resolves successfully. Skipped when the underlying writeText rejects.
aria-labelstring"Copy to clipboard"Accessible label announced to screen readers. Override when you have richer context like "Copy command" or "Copy email".
classNamestringForwarded to the root motion.button. Use to swap padding, color, or border treatment without forking the component.

All other motion.button props (whileHover, whileTap, transition, style, every native <button> attribute) are forwarded through.

Accessibility

The root element is a real <button type="button"> — Enter and Space trigger the copy, focus rings respect your theme tokens (focus-visible:ring-ring/50), and screen readers announce the button role plus the active label. A visually-hidden <span class="sr-only"> toggles between "Copy" and "Copied" so assistive tech announces the state change as the icon morphs.

The type="button" default is deliberate: dropping the component inside a <form> won't accidentally submit it when the user clicks copy.

How the animation works

The icon swap runs through AnimatePresence with mode="wait" — the outgoing icon finishes its scale: 0.8 → opacity: 0 exit before the incoming one starts its mirror-image enter, so you never see the two glyphs overlap or stack. The transitions are short (150 ms) on purpose: the feedback should feel instant, not animated.

The press effect (whileTap) is GPU-accelerated by motion, so it stays smooth even when the click also triggers a heavy callback like persisting state or hitting an analytics endpoint.

Common gotchas

HTTPS in production. navigator.clipboard.writeText only works on secure contexts. It's enabled on localhost for dev but will reject on a plain-HTTP deployment — there's no graceful workaround in the browser, you need TLS.

Mobile Safari user-gesture rule. iOS only permits clipboard writes inside a direct user interaction. The button satisfies that out of the box. If you wrap the trigger in something that defers the write (a debounced handler, a setTimeout), iOS will silently reject it.

Async values. The value prop is read at click time, not generated then. If you need to compute the string lazily (sign a token, hit an API), generate it before render and pass the result, or hold the button disabled until the value is ready.