Copy Button
Icon morphs from copy to check with a blur-scale crossfade, then the check draws itself.
- AnimatePresence popLayout
- spring
- pathLength
- blur
npx shadcn add @tcosta/copy-button
Install
npx shadcn@latest add https://tcosta.dev/r/copy-button.jsonOr copy the files below into components/copy-button/. They use the shadcn/ui theme tokens and cn from @/lib/utils, and need motion.
Source
copy-button.tsx
'use client';
import { AnimatePresence, motion, type Variants } from 'motion/react';
import type { ComponentProps } from 'react';
import { cn } from '@/lib/utils';
import { CheckIcon, CopyIcon } from './icons';
import { useCopyToClipboard } from './use-copy-to-clipboard';
type CopyButtonProps = Omit<ComponentProps<'button'>, 'onClick' | 'children'> & {
value: string;
resetAfter?: number;
onCopy?: (value: string) => void;
};
const iconVariants: Variants = {
hidden: { opacity: 0, scale: 0.5, filter: 'blur(4px)' },
visible: { opacity: 1, scale: 1, filter: 'blur(0px)' },
};
export function CopyButton({
value,
resetAfter = 1800,
onCopy,
className,
...props
}: CopyButtonProps) {
const { copied, copy } = useCopyToClipboard({ resetAfter, onCopy });
return (
<button
type="button"
onClick={() => copy(value)}
aria-label={copied ? 'Copied' : 'Copy to clipboard'}
className={cn(
'relative inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors outline-none',
'hover:bg-accent hover:text-accent-foreground',
'focus-visible:ring-[3px] focus-visible:ring-ring/50',
'active:scale-[0.97]',
className,
)}
{...props}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={copied ? 'check' : 'copy'}
variants={iconVariants}
initial="hidden"
animate="visible"
exit="hidden"
transition={{ type: 'spring', duration: 0.3, bounce: 0.25 }}
className="flex"
>
{copied ? <CheckIcon /> : <CopyIcon />}
</motion.span>
</AnimatePresence>
<span className="sr-only" aria-live="polite">
{copied ? 'Copied to clipboard' : ''}
</span>
</button>
);
}
use-copy-to-clipboard.ts
import { useEffect, useRef, useState } from 'react';
type UseCopyToClipboardOptions = {
resetAfter: number;
onCopy?: (value: string) => void;
};
export function useCopyToClipboard({ resetAfter, onCopy }: UseCopyToClipboardOptions) {
const [copied, setCopied] = useState(false);
const timeout = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(timeout.current), []);
const copy = async (value: string) => {
try {
await navigator.clipboard.writeText(value);
} catch {
return; // Clipboard blocked (e.g. insecure context): don't fake a "copied".
}
onCopy?.(value);
setCopied(true);
clearTimeout(timeout.current);
timeout.current = setTimeout(() => setCopied(false), resetAfter);
};
return { copied, copy };
}
icons.tsx
import { motion } from 'motion/react';
import type { ComponentProps } from 'react';
function Icon({ children, ...props }: ComponentProps<'svg'>) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
{...props}
>
{children}
</svg>
);
}
export function CopyIcon() {
return (
<Icon>
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</Icon>
);
}
export function CheckIcon() {
return (
<Icon strokeWidth="2.5">
<motion.path
d="M20 6 9 17l-5-5"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.25, ease: 'easeOut', delay: 0.05 }}
/>
</Icon>
);
}