A copy-to-clipboard button with a morphing check icon, an optional label, a fallback for blocked clipboards, an error state and a polite announcement.
import { CopyButton } from "@/components/ballmac/copy-button"
export default function CopyButtonDemo() {
return (
<div className="grid w-full max-w-sm gap-4">
<div className="flex flex-wrap items-center gap-3">
<CopyButton value="pnpm add @acme/ui" ariaLabel="Copy install command" />
<CopyButton variant="outline" label="Copy" value="pnpm add @acme/ui" />
<CopyButton variant="solid" label="Copy link" copiedLabel="Link copied" value="https://example.com/docs" />
</div>
<div className="flex flex-wrap items-center gap-3">
<CopyButton size="sm" variant="outline" ariaLabel="Copy small" value="small" />
<CopyButton size="default" variant="outline" ariaLabel="Copy default" value="default" />
<CopyButton size="lg" variant="outline" ariaLabel="Copy large" value="large" />
</div>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/copy-buttonInstall the dependencies.
$ pnpm add motion@^12 lucide-react class-variance-authorityAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/copy-button.tsx// Ballmac UI: Copy Button. https://ui.ballmac.com/components/copy-button "use client" import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { AnimatePresence, motion, useReducedMotion } from "motion/react" import { AlertCircle, Check, Copy } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" const copyButtonVariants = cva( "relative inline-flex shrink-0 items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap outline-none transition-[color,background-color,border-color,box-shadow] duration-150 select-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none [&_svg]:shrink-0", { variants: { variant: { ghost: "text-muted-foreground hover:bg-accent hover:text-foreground", outline: "border bg-background text-foreground shadow-xs hover:bg-accent", solid: "bg-primary text-primary-foreground hover:bg-primary/90", }, size: { sm: "h-7 text-xs [&_svg]:size-3.5", default: "h-8 text-[13px] [&_svg]:size-4", lg: "h-10 [&_svg]:size-4", }, }, defaultVariants: { variant: "ghost", size: "default" }, } ) /** Writes text to the clipboard, falling back to a hidden textarea where the async API is blocked (insecure origins, some iframes). */ async function copyText(text: string): Promise<boolean> { try { await navigator.clipboard.writeText(text) return true } catch { try { const area = document.createElement("textarea") area.value = text area.setAttribute("readonly", "") area.style.cssText = "position:fixed;top:0;left:0;opacity:0;pointer-events:none" document.body.appendChild(area) area.select() const ok = document.execCommand("copy") area.remove() return ok } catch { return false } } } type CopyButtonProps = Omit<React.ComponentProps<"button">, "value" | "onCopy" | "children"> & VariantProps<typeof copyButtonVariants> & { /** Text to copy. */ value?: string /** Reads the text at click time instead, for content that changes (a selection, a form). May be async. */ getValue?: () => string | Promise<string> /** Visible label next to the icon, such as "Copy". Leave out for an icon-only button. */ label?: string /** Label shown after a successful copy. */ copiedLabel?: string /** Accessible name of an icon-only button. */ ariaLabel?: string /** How long the confirmation stays, in milliseconds. */ resetAfter?: number /** Called with the copied text. */ onCopied?: (text: string) => void /** Called when both clipboard routes fail. */ onError?: () => void } function CopyButton({ value, getValue, label, copiedLabel, ariaLabel, resetAfter = 1800, onCopied, onError, variant, size, className, onClick, ...props }: CopyButtonProps) { const msg = useMessages() copiedLabel ??= msg("copy-button.copiedLabel", "Copied") ariaLabel ??= msg("copy-button.ariaLabel", "Copy to clipboard") const reduce = useReducedMotion() const [state, setState] = React.useState<"idle" | "copied" | "failed">("idle") const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined) React.useEffect(() => () => clearTimeout(timer.current), []) async function handleClick(event: React.MouseEvent<HTMLButtonElement>) { onClick?.(event) if (event.defaultPrevented) return const text = getValue ? await getValue() : (value ?? "") const ok = await copyText(text) setState(ok ? "copied" : "failed") if (ok) onCopied?.(text) else onError?.() clearTimeout(timer.current) timer.current = setTimeout(() => setState("idle"), resetAfter) } const word = state === "copied" ? copiedLabel : state === "failed" ? "Copy failed" : label const Icon = state === "copied" ? Check : state === "failed" ? AlertCircle : Copy return ( <> <button type="button" data-slot="copy-button" data-state={state} aria-label={word ?? ariaLabel} title={word ?? ariaLabel} onClick={handleClick} className={cn( copyButtonVariants({ variant, size }), label ? { sm: "px-2", default: "px-2.5", lg: "px-3.5" }[size ?? "default"] : { sm: "w-7", default: "w-8", lg: "w-10" }[size ?? "default"], state === "copied" && "text-foreground", className )} {...props} > <span aria-hidden="true" className="relative flex items-center justify-center"> <AnimatePresence mode="popLayout" initial={false}> <motion.span key={state} className="flex" initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, rotate: -45 }} animate={{ opacity: 1, scale: 1, rotate: 0 }} exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5 }} transition={{ type: "spring", stiffness: 520, damping: 30 }} > <Icon className={cn(state === "copied" && "text-chart-2", state === "failed" && "text-destructive")} /> </motion.span> </AnimatePresence> </span> {label && ( <span data-label="" aria-hidden="true"> {word} </span> )} </button> <span className="sr-only" role="status" aria-live="polite"> {state === "copied" ? "Copied to clipboard" : state === "failed" ? "Copy failed" : ""} </span> </> ) } export { CopyButton, copyButtonVariants, copyText, type CopyButtonProps }Update the import paths to match your project setup.
Usage
import { CopyButton, copyText } from "@/components/ballmac/copy-button"The full example is in the Code tab above.
Examples
Inline with a value
- Project ID
prj_8f3a1c92e7 - Region
eu-west-1 - Webhook URL
https://api.example.com/hooks/in/8f3a
import { CopyButton } from "@/components/ballmac/copy-button"
export default function CopyButtonInline() {
return (
<ul className="grid w-full max-w-sm grid-cols-[minmax(0,1fr)] gap-2 text-sm">
{[
["Project ID", "prj_8f3a1c92e7"],
["Region", "eu-west-1"],
["Webhook URL", "https://api.example.com/hooks/in/8f3a"],
].map(([label, value]) => (
<li key={label} className="flex min-w-0 items-center gap-2 rounded-lg border bg-card py-1.5 pe-1.5 ps-3">
<span className="w-24 shrink-0 text-muted-foreground">{label}</span>
<code className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">{value}</code>
<CopyButton value={value!} ariaLabel={`Copy ${label}`} />
</li>
))}
</ul>
)
}API reference
| Prop | Type | Default |
|---|---|---|
valueText to copy. | string | — |
getValueReads the text at click time instead, for content that changes (a selection, a form). May be async. | () => string | Promise<string> | — |
labelVisible label next to the icon, such as "Copy". Leave out for an icon-only button. | string | — |
copiedLabelLabel shown after a successful copy. | string | — |
ariaLabelAccessible name of an icon-only button. | string | — |
resetAfterHow long the confirmation stays, in milliseconds. | number | 1800 |
onCopiedCalled with the copied text. | (text: string) => void | — |
onErrorCalled when both clipboard routes fail. | () => void | — |
variant | "ghost" | "outline" | "solid" | "ghost" |
size | "sm" | "default" | "lg" | "default" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space | Copies and confirms |
| Screen readers | Name changes to 'Copied' and a polite status announces it; failures say 'Copy failed' |
| Reduced motion | The icon swaps with a fade only |
Use with AI
Pass value (or getValue for content read at click time). label adds visible text, variant is ghost | outline | solid, size sm | default | lg. Falls back to execCommand when navigator.clipboard is blocked and shows 'Copy failed' if both fail. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Copy Button (@ballmac/copy-button) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Any value a developer will paste: keys, URLs, commands, IDs
- Toolbars on code, logs and config
Not for
- Copying a whole code block with a header (code-block has its own button)
Registry JSON: https://ui.ballmac.com/r/copy-button.json
Credits
Free to use in personal and commercial projects.
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Code Block
A code panel with filename and language header, line numbers, highlighted lines, a wrap toggle, copy feedback and file tabs. No highlighter bundled; pass Shiki output as children.
API Key Field
Displays a secret such as an API key, masked by default (sk-live-••••••••a1b2), with a reveal toggle, a copy button that copies the full key, and an optional regenerate action.
Terminal
A terminal window with command, output, success, error and comment lines, optional per-command copy buttons and a sequenced typing animation that respects reduced motion.
API Endpoint
An endpoint reference card: method and path with highlighted parameters, auth, grouped parameter lists, and request and response examples in tabs with copy.