Installation
Or, with @layro in your components.json:
Code
import { IconCheck, IconCopy } from "@tabler/icons-react";
import { AnimatePresence, motion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
/*
Copies text and confirms with an icon swap. Also published on Layro as
`copy-button` by @layro.
*/
export interface CopyButtonProps {
/** Text placed on the clipboard. */
value: string;
/** Visible label. Omit for an icon-only button (then `aria-label` is used). */
label?: string;
/** Label shown for a moment after copying. */
copiedLabel?: string;
/** Called after a successful copy, e.g. to count it. */
onCopy?: () => void;
/** `ghost` drops the button surface: just the icon (and label), for
sitting directly on code. */
variant?: "secondary" | "ghost";
className?: string;
"aria-label"?: string;
}
export function CopyButton({
value,
label,
copiedLabel = "Copied",
onCopy,
variant = "secondary",
className,
"aria-label": ariaLabel = "Copy",
}: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const timer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(timer.current), []);
async function copy() {
try {
await navigator.clipboard.writeText(value);
} catch {
return;
}
setCopied(true);
onCopy?.();
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), 1400);
}
return (
<button
type="button"
onClick={copy}
aria-label={label ? undefined : ariaLabel}
className={cn(
"inline-flex h-8 items-center justify-center gap-1.5 rounded-[9px] text-[13px] font-medium",
variant === "ghost"
? "text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-brand-500/60"
: "layro-cta--secondary layro-cta--sm",
label ? "px-2.5" : variant === "ghost" ? "w-8" : "px-2.5",
className,
)}
>
<span className="relative grid size-3.5 place-items-center">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={copied ? "check" : "copy"}
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{ duration: 0.16, ease: "easeOut" }}
className="absolute inset-0 grid place-items-center"
>
{copied ? (
<IconCheck size={14} className="text-brand-400" />
) : (
<IconCopy size={14} />
)}
</motion.span>
</AnimatePresence>
</span>
{label ? <span>{copied ? copiedLabel : label}</span> : null}
</button>
);
}Versions
1.0.0 · current
Oct 27, 2026
First release.
