Copies text to the clipboard while the copy icon spins into a self-drawing check mark and the label rolls to "Copied", then resets after a configurable delay. Optionally shows the copied value in an inline code field.
Preview
npx motionfoundry add copy-buttonCustomize
10 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add copy-buttonWrites components/ui/copy-button.tsx and components/styles/copy-button.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { CopyButton } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <CopyButton />6 );7}Props
Accessibility
Native <button>; when the value field is shown or the button is icon-only it gets an explicit aria-label ("Copy command" / "Copied"). A polite role="status" live region announces "Copied to clipboard". Icons are aria-hidden. Falls back to a hidden textarea + execCommand when the async Clipboard API is unavailable. Under prefers-reduced-motion the icon and label cross-fade without rotation, blur or path drawing.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { AnimatePresence, motion, useReducedMotion } from "motion/react";5import styles from "../styles/copy-button.module.css";6 7export interface CopyButtonProps {8 /** Text written to the clipboard */9 text?: string;10 /** Button label before copying */11 label?: string;12 /** Button label after a successful copy */13 copiedLabel?: string;14 /** Milliseconds before the button resets to its idle state */15 resetDelay?: number;16 /** Visual style */17 variant?: "default" | "outline" | "ghost";18 /** Size preset */19 size?: "sm" | "md" | "lg";20 /** Render the text in a code field beside the button */21 showValue?: boolean;22 /** Hide the visible label and show only the morphing icon */23 iconOnly?: boolean;24 /** Color of the check mark and copied state */25 accentColor?: string;26 /** Disable the button */27 disabled?: boolean;28 /** Called after the text has been copied */29 onCopy?: (text: string) => void;30 /** Optional className merged onto the root */31 className?: string;32 /** Optional inline styles merged onto the root */33 style?: React.CSSProperties;34}35 36async function writeClipboard(text: string): Promise<boolean> {37 try {38 if (navigator.clipboard?.writeText) {39 await navigator.clipboard.writeText(text);40 return true;41 }42 } catch {43 // fall through to the legacy path44 }45 try {46 const ta = document.createElement("textarea");47 ta.value = text;48 ta.setAttribute("readonly", "");49 ta.style.position = "fixed";50 ta.style.opacity = "0";51 document.body.appendChild(ta);52 ta.select();53 const ok = document.execCommand("copy");54 document.body.removeChild(ta);55 return ok;56 } catch {57 return false;58 }59}60 61export function CopyButton({62 text = "npx motionfoundry add copy-button",63 label = "Copy",64 copiedLabel = "Copied",65 resetDelay = 2000,66 variant = "outline",67 size = "md",68 showValue = true,69 iconOnly = false,70 accentColor = "#22c55e",71 disabled = false,72 onCopy,73 className = "",74 style,75}: CopyButtonProps) {76 const reduce = useReducedMotion();77 const [copied, setCopied] = React.useState(false);78 const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);79 80 React.useEffect(() => () => {81 if (timer.current) clearTimeout(timer.current);82 }, []);83 84 const copy = async () => {85 if (disabled) return;86 const ok = await writeClipboard(text);87 if (!ok) return;88 setCopied(true);89 onCopy?.(text);90 if (timer.current) clearTimeout(timer.current);91 timer.current = setTimeout(() => setCopied(false), Math.max(300, resetDelay));92 };93 94 const spring = reduce ? { duration: 0.1 } : { type: "spring" as const, stiffness: 520, damping: 28 };95 const iconMotion = reduce96 ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }97 : { initial: { opacity: 0, scale: 0.4, rotate: -45 }, animate: { opacity: 1, scale: 1, rotate: 0 }, exit: { opacity: 0, scale: 0.4, rotate: 45 } };98 99 return (100 <div101 className={[styles.root, styles[size], showValue ? styles.withValue : "", className].filter(Boolean).join(" ")}102 style={{ "--mf-cb-accent": accentColor, ...style } as React.CSSProperties}103 data-copied={copied || undefined}104 >105 {showValue ? (106 <code className={styles.value} title={text}>107 <span className={styles.prompt} aria-hidden="true">108 $109 </span>110 {text}111 </code>112 ) : null}113 <button114 type="button"115 disabled={disabled}116 onClick={copy}117 aria-label={iconOnly || showValue ? (copied ? copiedLabel : `${label}${showValue ? " command" : ""}`) : undefined}118 className={`${styles.button} ${styles[variant]} ${iconOnly ? styles.iconOnly : ""}`.trim()}119 >120 <span className={styles.icon} aria-hidden="true">121 <AnimatePresence initial={false} mode="popLayout">122 {copied ? (123 <motion.svg key="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className={styles.check} {...iconMotion} transition={spring}>124 <motion.path d="M5 12.5l4.5 4.5L19 7" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: reduce ? 0 : 0.28, delay: reduce ? 0 : 0.06 }} />125 </motion.svg>126 ) : (127 <motion.svg key="copy" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...iconMotion} transition={spring}>128 <rect x="9" y="9" width="12" height="12" rx="2" />129 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />130 </motion.svg>131 )}132 </AnimatePresence>133 </span>134 {iconOnly ? null : (135 <span className={styles.labelSlot} aria-hidden={showValue || undefined}>136 <span className={styles.sizer} aria-hidden="true">137 {label}138 </span>139 <span className={styles.sizer} aria-hidden="true">140 {copiedLabel}141 </span>142 <AnimatePresence initial={false} mode="popLayout">143 <motion.span144 key={copied ? "copied" : "idle"}145 className={styles.label}146 initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(2px)" }}147 animate={reduce ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}148 exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, filter: "blur(2px)" }}149 transition={{ duration: reduce ? 0.1 : 0.2, ease: [0.16, 1, 0.3, 1] }}150 >151 {copied ? copiedLabel : label}152 </motion.span>153 </AnimatePresence>154 </span>155 )}156 </button>157 <span className={styles.srOnly} role="status" aria-live="polite">158 {copied ? `${copiedLabel} to clipboard` : ""}159 </span>160 </div>161 );162}