A copy-to-clipboard button whose clipboard icon spins into a drawn tick while the label swaps to Copied, without the button changing width.
npx shadcn add @motionkit/copy-buttonCustomize
Usage
import { CopyButton } from "@/components/motionkit/copy-button";
<CopyButton text="npx shadcn add @motionkit/copy-button" />Motion
The exact curves this component runs, plotted with Motion's own maths.
Icon swap
Copied
Tick draw
Copied
Code blocks, API keys, invite links, referral codes and install commands: anywhere people copy something and need to know it worked.
Clicking writes the text with the Clipboard API, falling back to a hidden textarea and execCommand where that isn't available, such as some embedded browsers. On success, AnimatePresence swaps the clipboard icon for a tick, each one scaling and turning as it goes, and the tick draws in with pathLength. Both labels share one grid cell, so the button keeps its width as "Copy" becomes "Copied". A timer returns it to rest, and the change is announced in a status region.
Works in the App Router as is. Requires Tailwind CSS v4.
One command adds the file and installs its dependencies. Needs a components.json (run npx shadcn@latest init once if you don't have one).
npx shadcn@latest add https://motionkitui.com/r/copy-button.jsonnpm install motionSave as components/motionkit/copy-button.tsx.
"use client";
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type CopyButtonProps = {
/** What gets copied. */
text: string;
/** Button text before copying. Leave empty for an icon-only button. */
label?: string;
/** Shown, and announced, once copied. */
copiedLabel?: string;
/** Milliseconds before it returns to its resting state. */
resetAfter?: number;
/** Called after a successful copy. */
onCopy?: () => void;
className?: string;
};
/** Copies with the Clipboard API, falling back to a hidden textarea where it isn't available. */
async function copy(text: string) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const area = document.createElement("textarea");
area.value = text;
area.setAttribute("readonly", "");
area.style.position = "fixed";
area.style.opacity = "0";
document.body.appendChild(area);
area.select();
const ok = document.execCommand("copy");
area.remove();
return ok;
}
}
/** A copy-to-clipboard button: the clipboard icon turns into a drawn tick and the label swaps to "Copied". */
export function CopyButton({ text, label = "Copy", copiedLabel = "Copied", resetAfter = 1800, onCopy, className = "" }: CopyButtonProps) {
const reduceMotion = useReducedMotion();
const [copied, setCopied] = useState(false);
const timer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(timer.current), []);
async function handleClick() {
if (!(await copy(text))) return;
setCopied(true);
onCopy?.();
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setCopied(false), resetAfter);
}
const swap = reduceMotion
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
: { initial: { opacity: 0, scale: 0.6, rotate: -20 }, animate: { opacity: 1, scale: 1, rotate: 0 }, exit: { opacity: 0, scale: 0.6, rotate: 20 } };
return (
<button
type="button"
onClick={handleClick}
aria-label={label ? undefined : copied ? copiedLabel : "Copy to clipboard"}
className={`inline-flex items-center gap-2 rounded-lg border border-white/10 bg-[#141B1B] px-3 py-2 text-sm text-[#DCE3E2] transition-colors hover:border-white/20 hover:text-[#F6F1E8] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#5FAEB4] ${className}`}
>
<span aria-hidden className="relative size-4">
<AnimatePresence initial={false} mode="popLayout">
{copied ? (
<motion.svg key="tick" viewBox="0 0 16 16" className="absolute inset-0 size-4" fill="none" stroke="#5FAEB4" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" {...swap} transition={{ duration: 0.2 }}>
<motion.path d="M3 8.5l3 3 7-7" initial={{ pathLength: reduceMotion ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.3, delay: 0.05 }} />
</motion.svg>
) : (
<motion.svg key="clip" viewBox="0 0 16 16" className="absolute inset-0 size-4" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinejoin="round" {...swap} transition={{ duration: 0.2 }}>
<rect x="5" y="5" width="8.5" height="8.5" rx="1.75" />
<path d="M10.5 5V3.75A1.25 1.25 0 0 0 9.25 2.5h-5.5A1.25 1.25 0 0 0 2.5 3.75v5.5a1.25 1.25 0 0 0 1.25 1.25H5" />
</motion.svg>
)}
</AnimatePresence>
</span>
{label && (
<span className="relative inline-grid">
{/* both labels share one grid cell, so the button keeps its width as they swap */}
<span className={`col-start-1 row-start-1 transition-opacity ${copied ? "opacity-0" : "opacity-100"}`}>{label}</span>
<span aria-hidden className={`col-start-1 row-start-1 text-[#5FAEB4] transition-opacity ${copied ? "opacity-100" : "opacity-0"}`}>
{copiedLabel}
</span>
</span>
)}
<span role="status" className="sr-only">
{copied ? copiedLabel : ""}
</span>
</button>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | — | What gets copied. |
| label | string | "Copy" | Button text. Pass an empty string for an icon-only button. |
| copiedLabel | string | "Copied" | Shown and announced once copied. |
| resetAfter | number | 1800 | Milliseconds before it returns to rest. |
| onCopy | () => void | — | Called after a successful copy. |
How we audit: the WCAG 2.2 animation checklist and respecting reduced motion in React.
Yes. The file starts with "use client", so you can import it straight into a Server Component page or layout. It works in Vite as is, and in Astro as a React island.
Only the motion package, beside React and Tailwind CSS v4. There is no MotionKit package and no Tailwind config to add.
Yes. Run npx shadcn@latest add https://motionkitui.com/r/copy-button.json in any project with a components.json. It saves the file to components/motionkit and installs its dependencies.
With reduced motion on, the icons crossfade without spinning and the tick appears already drawn.
The Clipboard API needs a secure context (HTTPS or localhost) and a user gesture. Copy Button falls back to the older execCommand method, and only shows Copied if one of them succeeded.
Code Walkthrough
Explains code step by step: pick a step and a highlight band springs to its lines, the rest dim, and the panel scrolls them into view.
Magnetic Button
A button that leans toward the cursor with a springy, physical pull.
Shimmer Button
A dark button with a light sweep that draws the eye without shouting.