A copy-to-clipboard button whose icon swaps to a check with a spring scale-and-blur crossfade, then swaps back.
npx shadcn@latest add @paragon/copy-button"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/utils";
export interface CopyButtonProps
extends Omit<React.ComponentProps<"button">, "value" | "children"> {
/** The text written to the clipboard. */
value: string;
/** How long the check mark stays visible, in ms. */
duration?: number;
/** Called after the value has been written to the clipboard. */
onCopy?: () => void;
/** Disables the icon-swap and press motion. */
static?: boolean;
}
/**
* A copy-to-clipboard button. On copy, the icon swaps to a check with a
* spring scale-and-blur crossfade, then swaps back after `duration`.
*
* The visual is 28px; an `after:` pseudo-element extends the hit area to
* 40px. The reset timer is cleared on unmount and on re-copy.
*/
export function CopyButton({
value,
duration = 1500,
onCopy,
static: isStatic = false,
className,
onClick,
...props
}: CopyButtonProps) {
const [copied, setCopied] = React.useState(false);
const timeout = React.useRef<ReturnType<typeof setTimeout>>(null);
const reducedMotion = useReducedMotion();
React.useEffect(() => {
return () => {
if (timeout.current) clearTimeout(timeout.current);
};
}, []);
const icon = copied ? (
<Check className="size-3.5" />
) : (
<Copy className="size-3.5" />
);
return (
<button
type="button"
aria-label="Copy to clipboard"
data-copied={copied || undefined}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented) return;
navigator.clipboard.writeText(value).then(() => {
setCopied(true);
onCopy?.();
if (timeout.current) clearTimeout(timeout.current);
timeout.current = setTimeout(() => setCopied(false), duration);
});
}}
className={cn(
"relative flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
!isStatic && "pressable",
className,
)}
{...props}
>
{isStatic ? (
icon
) : (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={copied ? "check" : "copy"}
className="flex items-center justify-center"
initial={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={
reducedMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.25, filter: "blur(4px)" }
}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{icon}
</motion.span>
</AnimatePresence>
)}
<span aria-live="polite" className="sr-only">
{copied ? "Copied to clipboard" : ""}
</span>
</button>
);
}