Copy Confirmation
A copy button trades its icon for a drawn tick and its label for "Copied", then quietly changes back.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Copy Confirmation
*
* A copy button that trades its icon for a drawn tick and its label for
* "Copied", then quietly changes back.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The button surface is mixed from the inherited text color, so it reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `value`, `resetMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type CopyConfirmationProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Text written to the clipboard. */
value?: string;
/** Resting label. */
label?: string;
/** Label held during the confirmation. */
copiedLabel?: string;
/** How long the confirmation is held before it reverts, in ms. */
resetMs?: number;
/** Fires after each copy attempt. */
onCopy?: () => void;
};
type VariantConfig = {
restScale: number;
rotate: number;
spring: { type: "spring"; stiffness: number; damping: number };
crossfade: number;
drawDuration: number;
};
// Quality rule: the icons scale and turn, the label never does. Springs
// sit at or above a 0.8 damping ratio, so the incoming icon lands once —
// a bouncing tick on a button pressed dozens of times a day would be
// unbearable by the third press.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a swap — almost a straight crossfade. For toolbars and code
// blocks where several of these sit in a row.
subtle: {
restScale: 0.9,
rotate: 0,
spring: { type: "spring", stiffness: 560, damping: 46 },
crossfade: 0.12,
drawDuration: 0.16,
},
// The outgoing icon shrinks away, the tick draws itself in. All-purpose.
default: {
restScale: 0.72,
rotate: 0,
spring: { type: "spring", stiffness: 500, damping: 40 },
crossfade: 0.16,
drawDuration: 0.22,
},
// A small turn on the swap — for a single prominent "copy your API key"
// moment, not for a row of buttons.
playful: {
restScale: 0.55,
rotate: 14,
spring: { type: "spring", stiffness: 440, damping: 34 },
crossfade: 0.18,
drawDuration: 0.26,
},
};
const CONFIRM = "#34D399";
const CHECK_PATH = "M3.4 8.4 6.6 11.6 12.7 5";
/** Theme-adaptive neutral: `currentColor` is the text color this button
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface and a border that are
* correctly toned in either theme. The confirmation green stays literal:
* it is a state color, not a surface. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function CopyConfirmation({
variant = "default",
value = "mk_live_8Fq2Zt6bXe1yN4vRu0Ka",
label = "Copy",
copiedLabel = "Copied",
resetMs = 1600,
onCopy,
}: CopyConfirmationProps) {
const [copied, setCopied] = useState(false);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
useEffect(() => {
if (!copied) return;
const timer = setTimeout(() => setCopied(false), resetMs);
return () => clearTimeout(timer);
}, [copied, resetMs]);
const handleClick = async () => {
try {
await navigator.clipboard?.writeText(value);
} catch {
// Clipboard writes are refused on insecure origins and in sandboxed
// frames. The confirmation still runs — this file is the motion, and
// a button that looks dead would misrepresent it. Real apps should
// surface the failure here instead of swallowing it.
}
setCopied(true);
onCopy?.();
};
// Reduced motion: the swap becomes a plain crossfade and the tick
// arrives already drawn. The state change is still fully legible.
const swapTransition = reduceMotion
? { duration: cfg.crossfade, ease: "easeOut" as const }
: cfg.spring;
const fade = { duration: cfg.crossfade, ease: "easeOut" as const };
const drawTransition = reduceMotion
? { duration: 0 }
: { duration: cfg.drawDuration, ease: "easeOut" as const, delay: 0.04 };
const away = reduceMotion ? 1 : cfg.restScale;
const turn = reduceMotion ? 0 : cfg.rotate;
return (
<button
type="button"
onClick={handleClick}
aria-label={label}
style={{
position: "relative",
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "8px 13px",
borderRadius: 10,
// Buttons inherit neither color nor font, so both are set here.
background: tone(7),
color: "inherit",
border: `1px solid ${tone(14)}`,
fontSize: 13,
fontWeight: 550,
fontFamily: "inherit",
lineHeight: 1,
cursor: "pointer",
}}
>
{/* Both icons live in the same grid cell, so the button is sized by
the larger of the two and the swap can never nudge the label. */}
<span
aria-hidden
style={{ display: "grid", placeItems: "center", width: 14, height: 14 }}
>
<motion.span
initial={false}
animate={{
opacity: copied ? 0 : 1,
scale: copied ? away : 1,
rotate: copied ? -turn : 0,
}}
transition={{ ...swapTransition, opacity: fade }}
style={{ gridArea: "1 / 1", display: "grid", placeItems: "center" }}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<rect
x="5.6"
y="5.6"
width="8.8"
height="8.8"
rx="2.2"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M10.9 3.6A2.2 2.2 0 0 0 8.7 1.6H3.8A2.2 2.2 0 0 0 1.6 3.8v4.9a2.2 2.2 0 0 0 2.1 2.2"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</motion.span>
<motion.span
initial={false}
animate={{
opacity: copied ? 1 : 0,
scale: copied ? 1 : away,
rotate: copied ? 0 : turn,
}}
transition={{ ...swapTransition, opacity: fade }}
style={{ gridArea: "1 / 1", display: "grid", placeItems: "center" }}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
{/* Drawn rather than faded: the stroke arriving in one motion
reads as "it just happened", where a fade reads as "it was
already there". */}
<motion.path
d={CHECK_PATH}
stroke={CONFIRM}
strokeWidth="1.9"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: copied ? 1 : 0 }}
transition={drawTransition}
/>
</svg>
</motion.span>
</span>
{/* Same stacking trick for the words: the button reserves the width
of the longer label up front, so nothing reflows mid-swap. The
labels only ever cross-fade — text that scales reads as cheap. */}
<span
aria-hidden
style={{ display: "grid", placeItems: "center", whiteSpace: "nowrap" }}
>
<motion.span
initial={false}
animate={{ opacity: copied ? 0 : 1 }}
transition={fade}
style={{ gridArea: "1 / 1" }}
>
{label}
</motion.span>
<motion.span
initial={false}
animate={{ opacity: copied ? 1 : 0 }}
transition={fade}
style={{ gridArea: "1 / 1", color: CONFIRM }}
>
{copiedLabel}
</motion.span>
</span>
{/* The visible labels are decorative duplicates, so the announcement
comes from here instead of from a label that is always rendered. */}
<span
role="status"
aria-live="polite"
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{copied ? copiedLabel : ""}
</span>
</button>
);
}About this pattern
The smallest useful piece of feedback in a product: proof that the click landed. The icon swaps on a tight spring while the tick strokes itself in, the word changes underneath it, and both revert a second and a half later without being asked. The whole pattern is built around one constraint — the button must not resize. Icon and label are each stacked in a single grid cell so the button reserves the wider of the two states up front, which means the swap can happen without the surrounding row reflowing and without the label scaling. Icons may turn and scale; words never do.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Document page
Copy affordance that confirms in place instead of firing a toast.
Related patterns
- Clipboard Toasts StackRepeat copies push a short stack of confirmations that shuffle down and dim instead of piling up.
- Field Valid CheckA small mark strokes itself in at the right edge of a field the moment its value becomes acceptable.
- Changes Saved PillA pill drifts up from the toolbar to confirm an autosave, then dissolves.