primitives
Copy Button
A tactile copy-to-clipboard button that morphs its clipboard icon into a checkmark via Motion's SVG pathLength animation — the check draws itself in as the clipboard fades out, so the confirmation feels physically written rather than swapped. Click triggers a spring micro-bounce (a pressed-key feel) and the background trails to bg-accent; failures shake and fire onError. Icon-only and fully accessible: native button semantics, focus-visible ring, Enter/Space, and an aria-label that flips to 'Copied!' in the success window. Forwards its ref to the underlying button element (both the animated and reduced-motion branches), so callers can measure or position relative to it (e.g. a confetti burst origin).
Install
1. Register the namespace (once per project):
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
npx shadcn add @bottega/copy-buttonUsage
<CopyButton />Props
Standard props — see source.
Source
"use client";
import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
import {
motion,
useAnimationControls,
useReducedMotion,
} from "motion/react";
import type { HTMLMotionProps } from "motion/react";
type Size = "sm" | "md" | "lg";
type Variant = "ghost" | "outline";
export type CopyButtonProps = Omit<
HTMLMotionProps<"button">,
"style" | "value"
> & {
/** Text to copy to clipboard. Required. */
value: string;
/** ms to hold the success state before reverting. Default 2000. */
timeout?: number;
/** Fired after a successful copy. */
onCopy?: (value: string) => void;
/** Fired when clipboard write fails. */
onError?: (err: unknown) => void;
/** Icon + hit-area size. Default 'md'. */
size?: Size;
/** Visual treatment. 'ghost' (default) = no border; 'outline' = border ring. */
variant?: Variant;
};
const SIZES: Record<
Size,
{ box: string; icon: number; stroke: number; radius: string }
> = {
// radius: tight pill fit at sm, full radius at md/lg
sm: {
box: "h-7 w-7",
icon: 16,
stroke: 1.5,
radius: "rounded-[calc(var(--radius)-2px)]",
},
md: { box: "h-8 w-8", icon: 16, stroke: 1.5, radius: "rounded-[var(--radius)]" },
lg: { box: "h-10 w-10", icon: 20, stroke: 1.75, radius: "rounded-[var(--radius)]" },
};
// Motion JS timings are SECONDS (not the --duration CSS token, which is ~300ms UI).
const PRESS_SPRING = { type: "spring" as const, stiffness: 400, damping: 17 };
// custom cubic-bezier overshoot so the check tip "arrives" with authority
const DRAW_EASE = [0.22, 1, 0.36, 1] as const;
const DRAW_DURATION = 0.35;
const UNDRAW_DURATION = 0.2;
const FADE_DURATION = 0.25;
export const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(
function CopyButton(
{
value,
timeout = 2000,
onCopy,
onError,
size = "md",
variant = "ghost",
className = "",
"aria-label": ariaLabel,
onClick: consumerOnClick,
...props
},
ref,
) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
const animate = mounted && !reduce;
const [copied, setCopied] = useState(false);
const [errored, setErrored] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const controls = useAnimationControls();
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
const bounce = useCallback(async () => {
if (!animate) return;
// scale 0.94 → 1.02 → 1.0, springs into a physical key-press feel
await controls.start({ scale: 1.02, transition: PRESS_SPRING });
await controls.start({ scale: 1, transition: PRESS_SPRING });
}, [animate, controls]);
const shake = useCallback(async () => {
if (!animate) {
// reduced motion: no shake, error still surfaces via state/aria
return;
}
// translate(-2 → 2) × 2 cycles, transform-only
await controls.start({
x: [0, -2, 2, -2, 2, 0],
transition: { duration: 0.08 * 2, ease: "linear" },
});
controls.set({ x: 0 });
}, [animate, controls]);
const handleClick = useCallback(
async (e: React.MouseEvent<HTMLButtonElement>) => {
consumerOnClick?.(e);
if (e.defaultPrevented) return;
if (timer.current) clearTimeout(timer.current);
setErrored(false);
void bounce();
try {
await navigator.clipboard.writeText(value);
setCopied(true);
onCopy?.(value);
timer.current = setTimeout(() => setCopied(false), timeout);
} catch (err) {
setErrored(true);
onError?.(err);
void shake();
timer.current = setTimeout(() => setErrored(false), timeout);
}
},
[consumerOnClick, bounce, shake, value, timeout, onCopy, onError],
);
const s = SIZES[size];
const label = copied ? "Copied!" : (ariaLabel ?? "Copy to clipboard");
const root = [
"relative inline-flex items-center justify-center",
"cursor-pointer select-none",
s.box,
s.radius,
"text-foreground",
// bg-accent on success trails the icon; color is information so it applies
// even under reduced motion (handled by the CSS transition below).
copied ? "bg-accent" : "bg-background hover:bg-muted",
variant === "outline" ? "border border-border" : "",
"transition-colors duration-[var(--duration)] ease-[var(--ease)]",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
className,
]
.filter(Boolean)
.join(" ");
// Geometry tuned to a 24x24 viewBox; rendered at icon size.
const dim = { width: s.icon, height: s.icon };
// Reduced-motion / pre-mount: instant opacity cut, no path animation, no spring.
if (!animate) {
return (
<button
{...stripMotionProps(props)}
ref={ref}
type="button"
onClick={handleClick}
aria-label={label}
className={root}
>
<span className="relative" style={dim}>
<ClipboardSvg
size={s.icon}
stroke={s.stroke}
style={{ opacity: copied ? 0 : 1 }}
/>
<CheckSvg
size={s.icon}
stroke={s.stroke}
style={{ opacity: copied ? 1 : 0 }}
drawn={copied}
/>
</span>
</button>
);
}
return (
<motion.button
{...props}
ref={ref}
type="button"
onClick={handleClick}
aria-label={label}
className={root}
animate={controls}
initial={{ scale: 1, x: 0 }}
whileTap={{ scale: 0.94 }}
style={{ transformOrigin: "center" }}
>
<span className="relative" style={dim}>
{/* Clipboard layer — fades out as the check draws in */}
<motion.span
className="absolute inset-0"
initial={false}
animate={{ opacity: copied ? 0 : 1, scale: copied ? 1 : 1 }}
whileHover={copied ? undefined : { scale: 1.05 }}
transition={{ duration: FADE_DURATION, ease: "easeOut" }}
>
<ClipboardSvg size={s.icon} stroke={s.stroke} animated />
</motion.span>
{/* Check layer — pathLength draws in (0→1) / undraws (1→0) */}
<motion.span
className="absolute inset-0"
initial={false}
animate={{ opacity: copied ? 1 : 0 }}
transition={{ duration: copied ? 0.12 : FADE_DURATION, ease: "easeOut" }}
>
<CheckSvg
size={s.icon}
stroke={s.stroke}
animated
pathLength={copied ? 1 : 0}
drawDuration={copied ? DRAW_DURATION : UNDRAW_DURATION}
/>
</motion.span>
</span>
</motion.button>
);
},
);
CopyButton.displayName = "CopyButton";
/** Strip motion-only gesture props before spreading onto a native button. */
function stripMotionProps(props: Record<string, unknown>) {
const {
whileHover: _wh,
whileTap: _wt,
whileFocus: _wf,
whileDrag: _wd,
whileInView: _wi,
initial: _i,
animate: _a,
exit: _e,
transition: _t,
variants: _v,
...rest
} = props;
void _wh;
void _wt;
void _wf;
void _wd;
void _wi;
void _i;
void _a;
void _e;
void _t;
void _v;
return rest;
}
type IconProps = {
size: number;
stroke: number;
style?: React.CSSProperties;
};
function ClipboardSvg({
size,
stroke,
style,
animated,
}: IconProps & { animated?: boolean }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
style={style}
className={animated ? "absolute inset-0" : undefined}
>
<rect x="8" y="2" width="8" height="4" rx="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
</svg>
);
}
function CheckSvg({
size,
stroke,
style,
animated,
drawn,
pathLength,
drawDuration = 0.35,
}: IconProps & {
animated?: boolean;
drawn?: boolean;
pathLength?: number;
drawDuration?: number;
}) {
// The check starts at the same stroke origin as the clipboard so it reads as
// a morph, not a swap: it "writes" the confirmation in.
if (!animated) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
style={style}
className="absolute inset-0"
>
<path
d="M5 12.5l4.5 4.5L19 7"
style={{ opacity: drawn ? 1 : 0 }}
/>
</svg>
);
}
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={stroke}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
style={style}
className="absolute inset-0"
>
<motion.path
d="M5 12.5l4.5 4.5L19 7"
initial={false}
animate={{ pathLength: pathLength ?? 0 }}
transition={{ duration: drawDuration, ease: DRAW_EASE }}
/>
</svg>
);
}
Dependencies
- motion
- @bottega/tokens