UIInteractive Buttons and Controlsmotion/react

CopyButton

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-button

Customize

10 props
2000

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add copy-button

Writes 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.

example.tsx
1import { CopyButton } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <CopyButton />6  );7}

Props

PropTypeDefault
text

Text written to the clipboard.

string'npx motionfoundry add copy-button'
label

Label before copying.

string'Copy'
copiedLabel

Label after a successful copy.

string'Copied'
resetDelay

Milliseconds before the button returns to idle (minimum 300).

number2000
variant

Visual style.

'default' | 'outline' | 'ghost''outline'
size

Size preset.

'sm' | 'md' | 'lg''md'
showValue

Render the text in a monospace field beside the button.

booleantrue
iconOnly

Hide the label and show only the morphing icon.

booleanfalse
accentColor

Color of the check mark and copied state.

string'#22c55e'
disabled

Disable the button.

booleanfalse
onCopy

Called after the text was copied successfully.

(text: string) => voidundefined
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/copy-button.tsx
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}