"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type CopyStatus = "idle" | "copied" | "error";
export interface CopyButtonProps
extends Omit<
React.ComponentPropsWithoutRef<"button">,
"value" | "children" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart" | "onAnimationEnd" | "onCopy"
> {
/** Text to copy. */
value?: string;
/** Resolves the text at click time instead of `value` (may be async). */
getValue?: () => string | Promise<string>;
/** How long the copied / error state is shown, in ms. */
timeout?: number;
/** Action label: the accessible name of the icon variant, the pill text and the idle tooltip. */
label?: string;
/** Shown and announced after a successful copy. */
copiedLabel?: string;
/** Shown and announced when the clipboard is unavailable. */
errorLabel?: string;
/** Icon-only button, an inline chip that shows the value, or a pill with a label. */
variant?: "icon" | "inline" | "pill";
/** Show a small tooltip above the icon and inline variants. */
tooltip?: boolean;
/** Check mark and highlight colour. */
accent?: string;
/** Inline variant only: content to display instead of `value`. */
children?: React.ReactNode;
/** Called with the copied text. */
onCopy?: (text: string) => void;
/** Called when copying fails. */
onCopyError?: (error: unknown) => void;
}
const EASE = [0.22, 1, 0.36, 1] as const;
/** Clipboard API first; a hidden textarea + execCommand fallback for insecure contexts. */
async function writeToClipboard(text: string) {
if (typeof navigator !== "undefined" && navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Permission denied or document not focused: try the legacy path below.
}
}
const previousFocus = document.activeElement as HTMLElement | null;
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.setAttribute("aria-hidden", "true");
Object.assign(textarea.style, { position: "fixed", top: "0", left: "0", opacity: "0", pointerEvents: "none" });
document.body.appendChild(textarea);
textarea.select();
let ok = false;
try {
ok = document.execCommand("copy");
} finally {
textarea.remove();
previousFocus?.focus({ preventScroll: true });
}
if (!ok) throw new Error("Clipboard unavailable");
}
function Glyphs({ status, instant }: { status: CopyStatus; instant: boolean }) {
const t = instant ? { duration: 0 } : { duration: 0.28, ease: EASE };
const draw = (on: boolean, delay = 0) => ({
initial: false as const,
animate: { pathLength: on ? 1 : 0, opacity: on ? 1 : 0 },
transition: instant ? { duration: 0 } : { duration: on ? 0.32 : 0.12, delay: on ? 0.08 + delay : 0, ease: EASE },
});
return (
<span aria-hidden="true" className="grid size-4 shrink-0 place-items-center [&>*]:col-start-1 [&>*]:row-start-1">
<motion.svg
viewBox="0 0 16 16"
fill="none"
className="size-4"
initial={false}
animate={status === "idle" ? { opacity: 1, scale: 1, rotate: 0 } : { opacity: 0, scale: 0.6, rotate: -12 }}
transition={t}
>
<rect x="5.25" y="5.25" width="8" height="8" rx="1.75" stroke="currentColor" strokeWidth="1.3" />
<path d="M10.75 3.25v-.5a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</motion.svg>
<svg viewBox="0 0 16 16" fill="none" className="size-4 text-[color-mix(in_oklab,var(--cb-accent)_80%,currentColor)]">
<motion.path d="M3.5 8.5l3 3 6-7" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" {...draw(status === "copied")} />
</svg>
<svg viewBox="0 0 16 16" fill="none" className="size-4 text-red-500 dark:text-red-400">
<motion.path d="M4.75 4.75l6.5 6.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" {...draw(status === "error")} />
<motion.path d="M11.25 4.75l-6.5 6.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" {...draw(status === "error", 0.1)} />
</svg>
</span>
);
}
/**
* Copies text to the clipboard and confirms it: the clipboard icon crossfades into a check that
* draws itself in, a tooltip says "Copied", and a live region announces it. Falls back to
* execCommand where the Clipboard API is unavailable and shows an error state if both fail.
*/
export const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(function CopyButton(
{
value,
getValue,
timeout = 1600,
label = "Copy",
copiedLabel = "Copied",
errorLabel = "Copy failed",
variant = "icon",
tooltip = true,
accent = "#FF7448",
children,
onCopy,
onCopyError,
className = "",
style,
onClick,
...props
},
ref,
) {
const reduceMotion = !!useReducedMotion();
const [status, setStatus] = React.useState<CopyStatus>("idle");
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
React.useEffect(() => () => clearTimeout(timer.current), []);
const show = (next: CopyStatus) => {
clearTimeout(timer.current);
setStatus(next);
timer.current = setTimeout(() => setStatus("idle"), Math.max(timeout, 0));
};
const copy = async () => {
try {
const text = getValue ? await getValue() : (value ?? "");
await writeToClipboard(text);
show("copied");
onCopy?.(text);
} catch (error) {
show("error");
onCopyError?.(error);
}
};
const stateLabel = status === "copied" ? copiedLabel : status === "error" ? errorLabel : label;
const showTooltip = tooltip && variant !== "pill";
const shape =
variant === "icon"
? "size-9 justify-center rounded-lg"
: variant === "inline"
? "h-9 max-w-full gap-3 rounded-lg pl-3 pr-2.5 font-mono text-[13px]"
: "h-9 gap-2 rounded-full px-4 text-sm font-medium tracking-tight";
const tone =
status === "copied"
? "border-[color-mix(in_oklab,var(--cb-accent)_55%,transparent)]"
: status === "error"
? "border-red-500/50"
: "border-zinc-900/10 hover:border-zinc-900/25 dark:border-white/10 dark:hover:border-white/25";
return (
<span
className="group/cb relative inline-flex max-w-full"
data-status={status}
style={{ ["--cb-accent" as string]: accent }}
>
<button
ref={ref}
type="button"
aria-label={variant === "icon" ? label : undefined}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) void copy();
}}
className={`inline-flex min-w-0 select-none items-center border bg-white/70 text-zinc-700 transition-[border-color,background-color,color] duration-200 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--cb-accent)] disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white/[0.04] dark:text-zinc-300 dark:hover:text-white ${shape} ${tone} ${className}`}
style={style}
{...props}
>
{variant === "inline" && (
<span className="min-w-0 truncate text-zinc-900 dark:text-zinc-100">
<span className="sr-only">{label}: </span>
{children ?? value}
</span>
)}
<Glyphs status={status} instant={reduceMotion} />
{variant === "pill" && (
<span className="relative inline-grid overflow-hidden [&>*]:col-start-1 [&>*]:row-start-1">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={status}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ duration: reduceMotion ? 0.1 : 0.22, ease: EASE }}
>
{stateLabel}
</motion.span>
</AnimatePresence>
</span>
)}
</button>
{showTooltip && (
<span
aria-hidden="true"
className={`pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 whitespace-nowrap rounded-md bg-zinc-900 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.14em] text-white transition-[opacity,translate] duration-200 motion-reduce:transition-none dark:bg-white dark:text-zinc-900 ${
status === "idle"
? "translate-y-1 opacity-0 group-hover/cb:translate-y-0 group-hover/cb:opacity-100 group-has-[:focus-visible]/cb:translate-y-0 group-has-[:focus-visible]/cb:opacity-100"
: "translate-y-0 opacity-100"
}`}
>
{stateLabel}
</span>
)}
<span role="status" aria-live="polite" className="sr-only">
{status === "copied" ? copiedLabel : status === "error" ? errorLabel : ""}
</span>
</span>
);
});