## 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}`}
>
{labels.map(([key, text]) => (
{text}
))}
{status === "copied" ? copiedLabel : status === "error" ? errorLabel : ""}
);
}
```