Buttons

Copy Button

A copy-to-clipboard button whose icon swaps to a check with a spring scale-and-blur crossfade, then swaps back.

Install

npx shadcn@latest add @paragon/copy-button

copy-button.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/utils";

export interface CopyButtonProps
  extends Omit<React.ComponentProps<"button">, "value" | "children"> {
  /** The text written to the clipboard. */
  value: string;
  /** How long the check mark stays visible, in ms. */
  duration?: number;
  /** Called after the value has been written to the clipboard. */
  onCopy?: () => void;
  /** Disables the icon-swap and press motion. */
  static?: boolean;
}

/**
 * A copy-to-clipboard button. On copy, the icon swaps to a check with a
 * spring scale-and-blur crossfade, then swaps back after `duration`.
 *
 * The visual is 28px; an `after:` pseudo-element extends the hit area to
 * 40px. The reset timer is cleared on unmount and on re-copy.
 */
export function CopyButton({
  value,
  duration = 1500,
  onCopy,
  static: isStatic = false,
  className,
  onClick,
  ...props
}: CopyButtonProps) {
  const [copied, setCopied] = React.useState(false);
  const timeout = React.useRef<ReturnType<typeof setTimeout>>(null);
  const reducedMotion = useReducedMotion();

  React.useEffect(() => {
    return () => {
      if (timeout.current) clearTimeout(timeout.current);
    };
  }, []);

  const icon = copied ? (
    <Check className="size-3.5" />
  ) : (
    <Copy className="size-3.5" />
  );

  return (
    <button
      type="button"
      aria-label="Copy to clipboard"
      data-copied={copied || undefined}
      onClick={(event) => {
        onClick?.(event);
        if (event.defaultPrevented) return;
        navigator.clipboard.writeText(value).then(() => {
          setCopied(true);
          onCopy?.();
          if (timeout.current) clearTimeout(timeout.current);
          timeout.current = setTimeout(() => setCopied(false), duration);
        });
      }}
      className={cn(
        "relative flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 ease-out hover:text-foreground after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
        !isStatic && "pressable",
        className,
      )}
      {...props}
    >
      {isStatic ? (
        icon
      ) : (
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.span
            key={copied ? "check" : "copy"}
            className="flex items-center justify-center"
            initial={
              reducedMotion
                ? { opacity: 0 }
                : { opacity: 0, scale: 0.25, filter: "blur(4px)" }
            }
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
            exit={
              reducedMotion
                ? { opacity: 0 }
                : { opacity: 0, scale: 0.25, filter: "blur(4px)" }
            }
            transition={{ type: "spring", duration: 0.3, bounce: 0 }}
          >
            {icon}
          </motion.span>
        </AnimatePresence>
      )}
      <span aria-live="polite" className="sr-only">
        {copied ? "Copied to clipboard" : ""}
      </span>
    </button>
  );
}