Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/copy-button.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
copy-button.tsx. It's yours: change anything.
Usage
import { CopyButton } from "@/components/copy-button"
export function InstallCommand() {
return <CopyButton value="npm i uxelith" label="Copy install command" />
}Source
One file, 156 lines, nothing hidden. Read it before you install it.
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface CopyButtonProps extends Omit<React.ComponentProps<"button">, "value" | "children"> {
/** Text to put on the clipboard. */
value: string;
/** Accessible name and tooltip, e.g. "Copy install command". */
label?: string;
/** Show the label as text next to the icon; it reads "Copied" for a moment after copying. */
showLabel?: boolean;
/** Milliseconds the "copied" state lasts. */
timeout?: number;
/** Called after a successful copy. */
onCopied?: (value: string) => void;
}
// Motion presets (ADR-010).
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;
const SPRING_BOUNCY = { type: "spring", visualDuration: 0.45, bounce: 0.35 } as const;
const icons = {
idle: (
<>
<rect x="5.25" y="5.25" width="8.5" height="8.5" rx="1.75" />
<path d="M10.75 2.75H4.25a1.5 1.5 0 0 0-1.5 1.5v6.5" />
</>
),
copied: <path d="m3.5 8.5 3 3 6-7" />,
failed: <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" />,
};
/**
* A button that copies text: the icon morphs into a tick (or a cross that
* shakes, if the copy failed), and a visible label reads "Copied".
*
* - The result is announced ("Copied" or "Couldn't copy") through a polite
* live region, not just shown.
* - Falls back to a hidden textarea where the Clipboard API isn't available
* (older browsers, non-HTTPS pages).
* - Under reduced motion the icons and label simply swap.
*/
export function CopyButton({
value,
label = "Copy",
showLabel = false,
timeout = 1800,
onCopied,
className,
onClick,
...props
}: CopyButtonProps) {
const [state, setState] = React.useState<"idle" | "copied" | "failed">("idle");
const [attempt, setAttempt] = React.useState(0);
const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);
const reduceMotion = useReducedMotion();
React.useEffect(() => () => clearTimeout(timer.current), []);
const copy = async () => {
let ok = false;
try {
await navigator.clipboard.writeText(value);
ok = true;
} catch {
const area = Object.assign(document.createElement("textarea"), { value, readOnly: true });
area.style.cssText = "position:fixed;opacity:0;pointer-events:none";
document.body.append(area);
area.select();
try {
ok = document.execCommand("copy");
} catch {
ok = false;
}
area.remove();
}
setState(ok ? "copied" : "failed");
setAttempt((n) => n + 1);
if (ok) onCopied?.(value);
clearTimeout(timer.current);
timer.current = setTimeout(() => setState("idle"), timeout);
};
const visibleLabel = state === "copied" ? "Copied" : state === "failed" ? "Try again" : label;
return (
<button
type="button"
title={showLabel ? undefined : label}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) void copy();
}}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md text-sm text-muted-foreground outline-none",
"transition-[color,background-color,scale] duration-150 active:scale-[0.96] motion-reduce:active:scale-100",
"hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
showLabel ? "px-2.5" : "w-8",
state === "copied" && "text-[var(--copy-success,var(--primary))] hover:text-[var(--copy-success,var(--primary))]",
state === "failed" && "text-destructive hover:text-destructive",
className,
)}
{...props}
>
<motion.span
key={state === "failed" ? `failed-${attempt}` : "icon"}
aria-hidden="true"
initial={false}
animate={state === "failed" && !reduceMotion ? { x: [0, -3, 3, -2, 2, 0] } : { x: 0 }}
transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
className="relative grid size-4 place-items-center"
>
<AnimatePresence initial={false} mode="popLayout">
<motion.svg
key={state}
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth={state === "idle" ? 1.5 : 1.75}
strokeLinecap="round"
strokeLinejoin="round"
className="size-4"
initial={{ opacity: 0, scale: 0.4, filter: "blur(3px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.4, filter: "blur(3px)" }}
transition={reduceMotion ? { duration: 0 } : state === "copied" ? SPRING_BOUNCY : SPRING_SNAPPY}
>
{icons[state]}
</motion.svg>
</AnimatePresence>
</motion.span>
{showLabel ? (
<span aria-hidden="true" className="relative">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={visibleLabel}
className="inline-block"
initial={{ opacity: 0, y: 6, filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -6, filter: "blur(2px)" }}
transition={reduceMotion ? { duration: 0 } : SPRING_SNAPPY}
>
{visibleLabel}
</motion.span>
</AnimatePresence>
</span>
) : null}
<span className="sr-only">{label}</span>
<span role="status" className="sr-only">
{state === "copied" ? "Copied" : state === "failed" ? "Couldn't copy" : ""}
</span>
</button>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | Text to copy. |
| label | string | "Copy" | Accessible name and tooltip. |
| showLabel | boolean | false | Show the label as text; it reads "Copied" for a moment after copying. |
| timeout | number | 1800 | Milliseconds the copied state lasts. |
| onCopied | (value: string) => void | — | Called after a successful copy. |
| --copy-success | CSS variable | --primary | Colour of the tick. |
Accessibility
- Announces "Copied" or "Couldn't copy" in a polite live region.
- Icon-only by default, always with an accessible name; the name stays the same while the visible label changes.
- Icons simply swap under prefers-reduced-motion.
Changelog
- v1.0.0Latest
First release.