Copy Button
A shadcn Button-based clipboard control with animated copy and success states.
buttonstate-transitionCSS-only alternative
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
const variants = {
hidden: { opacity: 0, scale: 0.5 },
visible: { opacity: 1, scale: 1 },
};
type ButtonProps = React.ComponentPropsWithoutRef<typeof Button>;
type ButtonClickEvent = Parameters<NonNullable<ButtonProps["onClick"]>>[0];
export type CopyButtonProps = Omit<
ButtonProps,
"children" | "value" | "onCopy"
> & {
/** Text written to the clipboard when the button is pressed. */
value: string;
/** How long, in ms, the copied state is shown before reverting. */
timeout?: number;
/** Called with the copied value after a successful write. */
onCopy?: (value: string) => void;
};
export function CopyButton({
value,
timeout = 1000,
onCopy,
onClick,
className,
variant = "outline",
size = "icon",
type = "button",
"aria-label": ariaLabel = "Copy to clipboard",
...props
}: CopyButtonProps) {
const [copied, setCopied] = React.useState(false);
const shouldReduceMotion = useReducedMotion();
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
const handleCopy = React.useCallback(
async (event: ButtonClickEvent) => {
onClick?.(event);
if (event.defaultPrevented) return;
try {
await navigator.clipboard.writeText(value);
} catch {
// Clipboard access can be denied (insecure context, no permission) —
// bail out without flipping into the copied state.
return;
}
onCopy?.(value);
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), timeout);
},
[onClick, onCopy, timeout, value],
);
// An icon swap is a tiny state change, so keep it snappy: ease-out, well
// under 150ms. Motion is removed entirely under prefers-reduced-motion.
const transition = shouldReduceMotion
? { duration: 0 }
: ({ duration: 0.13, ease: [0.215, 0.61, 0.355, 1] } as const);
return (
<Button
type={type}
variant={variant}
size={size}
aria-label={ariaLabel}
data-copied={copied}
onClick={handleCopy}
className={className}
{...props}
>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={copied ? "check" : "copy"}
variants={variants}
initial="hidden"
animate="visible"
exit="hidden"
transition={transition}
className="inline-flex"
>
{copied ? (
<Check data-icon="icon" aria-hidden="true" />
) : (
<Copy data-icon="icon" aria-hidden="true" />
)}
</motion.span>
</AnimatePresence>
<span role="status" aria-live="polite" className="sr-only">
{copied ? "Copied to clipboard" : ""}
</span>
</Button>
);
}Installation
The command installs the Motion version. The CSS-only source is available above as a manual copy-paste variant when you want to avoid the animation dependency.
pnpm dlx shadcn@latest add https://ui.ericts.com/r/copy-button.json