Copy Button
pnpm dlx shadcn@latest add …/r/copy-button.jsonAn accessible React copy to clipboard button for Next.js: the copy icon turns into a check, and screen readers hear "Copied".
- Ghost
- Outline
- With text
import { CopyButton } from "@/components/ui/copy-button"
export function CopyCommand() {
return <CopyButton value="npm install @fewtone/ui" />
}import { CopyButton } from "@/components/ui/copy-button";
import { useId } from "react";
export default function CopyButtonVariants() {
const id = useId();
return (
<ul className="flex flex-wrap items-end justify-center gap-x-10 gap-y-6">
<li className="flex flex-col items-center gap-2">
<CopyButton value="https://ui.fewtone.com" aria-describedby={`${id}-ghost`} />
<span id={`${id}-ghost`} className="text-xs text-muted-foreground">
Ghost
</span>
</li>
<li className="flex flex-col items-center gap-2">
<CopyButton value="https://ui.fewtone.com" variant="outline" aria-describedby={`${id}-outline`} />
<span id={`${id}-outline`} className="text-xs text-muted-foreground">
Outline
</span>
</li>
<li className="flex flex-col items-center gap-2">
<CopyButton value="https://ui.fewtone.com" variant="outline" copiedText="Copied">
Copy link
</CopyButton>
<span className="text-xs text-muted-foreground">With text</span>
</li>
</ul>
);
}Installation
Install the dependencies.
$ pnpm add lucide-react motionAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Add Button first.
Copy the source into your project.
"use client"; import { Button, type ButtonProps } from "@/components/ui/button"; import { duration, ease, spring, swap } from "@/lib/motion"; import { CopyIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type MouseEvent, type ReactNode, useEffect, useRef, useState } from "react"; /* * A Button that copies text to the clipboard (owner, 2026-10-14). * - Motion (.agents/rules/motion.md): the same swap as Loading Button, a blur with a 6px rise. The copy * icon lifts away as a check rises in and draws itself. With reduced motion it only fades. * - Width never jumps: both states are laid out invisibly in one cell, so a label can change * ("Copy link" → "Copied") without the button resizing. * - Icon only by default (ghost, icon size), named "Copy"; pass children for a label, and `icon={false}` * for text only (the label still swaps through the blur). * - A polite live region says "Copied". Back to the copy icon after `resetAfter`. * - Uses the Clipboard API, falling back to a hidden textarea where it's unavailable (older browsers, * pages not served over https). */ export type CopyButtonProps = Omit<ButtonProps, "value" | "asChild"> & { /** The text to copy */ value: string; /** Shown instead of the label once copied, for example "Copied" */ copiedText?: ReactNode; /** Called with the text after it's copied */ onCopy?: (value: string) => void; /** Milliseconds the check shows before the copy icon returns */ resetAfter?: number; /** Show the copy icon and the check. Set false for a text-only button; it then needs a label. */ icon?: boolean; }; const CHECK = "M5 12.5l4.5 4.5L19 7.5"; /** The check draws itself in; a 16px icon stroke, so drawing it costs nothing */ function Check({ reduce }: { reduce: boolean }) { return ( <svg viewBox="0 0 24 24" fill="none" aria-hidden> <motion.path d={CHECK} stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: duration.slow, ease: ease.out, delay: reduce ? 0 : duration.instant }} /> </svg> ); } /** The Clipboard API, or a hidden textarea where it's unavailable */ async function writeText(text: string) { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return; } const area = document.createElement("textarea"); area.value = text; area.setAttribute("readonly", ""); area.style.position = "fixed"; area.style.opacity = "0"; document.body.appendChild(area); area.select(); const copied = document.execCommand("copy"); area.remove(); if (!copied) throw new Error("Copy failed"); } function CopyButton({ value, copiedText, onCopy, resetAfter = 2000, icon = true, onClick, children, variant = "ghost", size, ...props }: CopyButtonProps) { const [copied, setCopied] = useState(false); // Counts copies, so copying again while the check shows starts its time over const [copies, setCopies] = useState(0); const reduce = useReducedMotion() ?? false; const mounted = useRef(true); useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); // The check gives way to the copy icon after a moment, counted from the latest copy // biome-ignore lint/correctness/useExhaustiveDependencies: each copy restarts the timer useEffect(() => { if (!copied) return; const timer = setTimeout(() => setCopied(false), resetAfter); return () => clearTimeout(timer); }, [copied, copies, resetAfter]); const content = (state: "idle" | "copied", live: boolean) => { const glyph = !icon ? null : live ? ( state === "copied" ? ( <Check reduce={reduce} /> ) : ( <CopyIcon aria-hidden /> ) ) : ( <span className="size-(--size-icon-md) shrink-0" /> ); const label = state === "copied" ? (copiedText ?? children) : children; return ( <> {glyph} {label} </> ); }; return ( <> <Button data-slot="copy-button" data-state={copied ? "copied" : "idle"} variant={variant} size={size ?? (children ? "md" : "icon")} aria-label={children ? undefined : "Copy"} onClick={async (event: MouseEvent<HTMLButtonElement>) => { onClick?.(event); if (event.defaultPrevented) return; try { await writeText(value); if (!mounted.current) return; setCopied(true); setCopies((count) => count + 1); onCopy?.(value); } catch { // The clipboard can be blocked; the text is still on the page to select } }} {...props} > <span className="grid"> {/* Both states, invisible, in one cell: the button is as wide as the wider */} {(["idle", "copied"] as const).map((state) => ( <span key={state} aria-hidden className="invisible col-start-1 row-start-1 flex items-center justify-center gap-2" > {content(state, false)} </span> ))} <AnimatePresence initial={false}> <motion.span key={copied ? "copied" : "idle"} {...(reduce ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } : swap)} transition={ reduce ? { duration: duration.fast } : { duration: duration.base, ease: ease.out, y: spring.snappy } } className="col-start-1 row-start-1 flex items-center justify-center gap-2" > {content(copied ? "copied" : "idle", true)} </motion.span> </AnimatePresence> </span> </Button> <span aria-live="polite" className="sr-only"> {copied ? "Copied" : ""} </span> </> ); } export { CopyButton };Update the import paths to match your project.
Examples
A command
Next to a command or a snippet, in a small size.
npx shadcn@latest add https://ui.fewtone.com/r/copy-button.jsonimport { CopyButton } from "@/components/ui/copy-button";
const command = "npx shadcn@latest add https://ui.fewtone.com/r/copy-button.json";
export default function CopyButtonCommand() {
return (
<div className="flex w-full max-w-lg items-center gap-2 rounded-lg bg-muted py-1 ps-4 pe-1">
<code className="min-w-0 flex-1 truncate font-mono text-sm">{command}</code>
<CopyButton value={command} size="icon-sm" aria-label="Copy the command" />
</div>
);
}Next to a field
For links and keys people need to paste somewhere else.
import { CopyButton } from "@/components/ui/copy-button";
import { Input } from "@/components/ui/input";
import { useId } from "react";
const link = "https://ui.fewtone.com/invite/k3x9";
export default function CopyButtonInput() {
const id = useId();
return (
<div className="grid w-full max-w-sm gap-2">
<label htmlFor={id} className="text-sm font-medium">
Invite link
</label>
<div className="flex gap-2">
<Input id={id} value={link} readOnly />
<CopyButton value={link} variant="outline" copiedText="Copied">
Copy
</CopyButton>
</div>
</div>
);
}API reference
Also accepts every Button prop except asChild. Uses the Clipboard API, falling back to a hidden textarea where it's unavailable. A polite live region says "Copied".
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | The text to copy. |
copiedText? | ReactNode | – | Shown instead of the label once copied. |
onCopy? | (value: string) => void | – | Called after the text is copied. |
resetAfter? | number | 2000 | Milliseconds the check shows before the copy icon returns. |
icon? | boolean | true | Shows the copy icon and the check. Set false for a text-only button with a label. |
variant? | Button variant | "ghost" | The button style. |
size? | Button size | "icon", or "md" with a label | The button size. |
Related components
An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.
- Loading
An accessible React loading button for Next.js that shows loading, success and error states for async actions, without changing width.
An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.