## Copy Button — Action Feedback Copy to tick, width locked, reverts after 2s. Docs: https://www.interior.dev/docs/copy-button Reference: https://www.interior.dev/reference/copy-button License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/copy-button.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx import { CopyButton } from "@/components/interior/copy-button"; export function ApiKeyRow({ token }: { token: string }) { return (
{token} track("api_key_copied")} />
); } ``` ### Props - `value` (`string`) The text written to the clipboard. An empty string is a no-op: the button never reports a success it did not perform. - `label` (`string`) — default: `"Copy"` Resting label, and the button's fixed accessible name, so the name never changes under the user mid-interaction. - `copiedLabel` (`string`) — default: `"Copied"` Success label. It shares a grid cell with the other two labels, so its width is reserved before the first click. - `errorLabel` (`string`) — default: `"Failed"` Shown when both the async clipboard and the selection fallback refuse. Failure is stated, not swallowed. - `timeout` (`number`) — default: `2000` Milliseconds the tick is held before reverting to rest. Each new copy restarts the clock rather than stacking timers. - `onCopy` (`(value: string) => void`) Fires only after the write actually resolves, with the exact string that reached the clipboard. - `onError` (`(reason: unknown) => void`) Fires with the rejection when every write path fails, including a denied permission in an insecure context. - `disabled` (`boolean`) — default: `false` Blocks the write and the press transform; the reserved width is unchanged so nothing around it moves. - `className` (`string`) — default: `""` Appended last, so callers can override any surface class on the button. ### Behavior notes - The three labels — rest, success, failure — occupy one grid cell, so the button is sized to its widest reachable state before the first click and neighbours never shift when the tick arrives. - A second click while the tick is showing restarts the two-second revert instead of stacking a second timer, and every timer is cleared on unmount, so a button removed mid-countdown cannot set state on a dead component. - The write is attempted through the async clipboard API and falls back to a detached textarea selection, restoring the user's prior selection range afterwards; when both refuse, the button says so rather than showing a tick it did not earn. - The accessible name is fixed to the resting label and the outcome is announced once through a polite live region, so a screen reader hears "Copied" a single time instead of a renamed control. - prefers-reduced-motion collapses the crossfade and the tick draw to zero duration: the state still changes, only the trip is skipped, and nothing is hidden. - Only opacity, scale, transform and stroke length are animated, and no state is written from an animation callback, so a rapid double click interrupts the spring from its current position instead of restarting it. ### Source (`components/interior/copy-button.tsx`) ```tsx "use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { motion, useReducedMotion } from "motion/react"; const EASE = [0.23, 1, 0.32, 1] as const; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const; const DRAW = { duration: 0.26, ease: EASE } as const; const INSTANT = { duration: 0 } as const; export type CopyStatus = "idle" | "copied" | "error"; export type UseCopyToClipboardOptions = { timeout?: number; onCopy?: (value: string) => void; onError?: (reason: unknown) => void; }; function writeFallback(text: string): boolean { const area = document.createElement("textarea"); area.value = text; area.setAttribute("readonly", ""); area.style.position = "fixed"; area.style.top = "0"; area.style.left = "0"; area.style.opacity = "0"; document.body.appendChild(area); const selection = document.getSelection(); const previous = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; area.select(); let ok = false; try { ok = document.execCommand("copy"); } catch { ok = false; } document.body.removeChild(area); if (selection && previous) { selection.removeAllRanges(); selection.addRange(previous); } return ok; } export function useCopyToClipboard({ timeout = 2000, onCopy, onError, }: UseCopyToClipboardOptions = {}) { const [status, setStatus] = useState("idle"); const [ticket, setTicket] = useState(0); const mounted = useRef(true); const copied = useRef(onCopy); copied.current = onCopy; const failed = useRef(onError); failed.current = onError; useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); const reset = useCallback(() => { setStatus("idle"); setTicket(0); }, []); const copy = useCallback(async (text: string) => { if (!text) return false; let ok = false; let reason: unknown = null; try { if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); ok = true; } else { ok = writeFallback(text); } } catch (error) { reason = error; try { ok = writeFallback(text); } catch { ok = false; } } if (!mounted.current) return ok; setStatus(ok ? "copied" : "error"); setTicket((t) => t + 1); if (ok) copied.current?.(text); else failed.current?.(reason); return ok; }, []); useEffect(() => { if (ticket === 0 || status === "idle") return; const id = setTimeout(() => setStatus("idle"), timeout); return () => clearTimeout(id); }, [ticket, status, timeout]); return { copy, reset, status, copied: status === "copied" }; } export type CopyButtonProps = { value: string; label?: string; copiedLabel?: string; errorLabel?: string; timeout?: number; onCopy?: (value: string) => void; onError?: (reason: unknown) => void; disabled?: boolean; className?: string; }; export function CopyButton({ value, label = "Copy", copiedLabel = "Copied", errorLabel = "Failed", timeout = 2000, onCopy, onError, disabled = false, className = "", }: CopyButtonProps) { const { copy, status } = useCopyToClipboard({ timeout, onCopy, onError }); const reduced = useReducedMotion(); const fade = reduced ? INSTANT : CROSSFADE; const draw = reduced ? INSTANT : DRAW; const labels: Array<[CopyStatus, string]> = [ ["idle", label], ["copied", copiedLabel], ["error", errorLabel], ]; return ( { void copy(value); }} whileTap={disabled || reduced ? undefined : { y: 1 }} transition={CELL} style={{ borderRadius: 9, touchAction: "manipulation" }} className={`inline-flex h-9 select-none items-center gap-2 rounded-[9px] border border-stone-200 bg-white px-3 text-[13px] font-medium text-stone-700 shadow-[inset_0_1.5px_0_rgba(255,255,255,0.95),inset_0_-1px_0_rgba(28,25,23,0.06),0_1px_2px_rgba(28,25,23,0.08)] outline-none transition-[border-color,box-shadow,background-color] duration-150 hover:bg-stone-50 focus-visible:border-[#4568FF] focus-visible:shadow-[0_1px_2px_rgba(28,25,23,0.08),0_10px_20px_-14px_rgba(69,104,255,0.6)] disabled:opacity-50 dark:border-white/[0.16] dark:bg-[#252522] dark:text-stone-200 dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.07),0_1px_2px_rgba(0,0,0,0.4)] dark:hover:bg-[#2A2A27] dark:focus-visible:border-[#93B0FF] dark:focus-visible:shadow-[0_10px_20px_-14px_rgba(147,176,255,0.5)] ${className}`} > {status === "copied" ? copiedLabel : status === "error" ? errorLabel : ""} ); } ```