Skip to content
Uxelith
Sign in
Freev1.0.0Actions

Copy Button

Copies text to the clipboard: the icon morphs into a tick (or a cross that shakes if it failed) and a visible label reads "Copied". Announces the result and falls back where the Clipboard API is missing.

pnpm dlx shadcn add …/r/copy-button.json
Licence
MIT
Packages
3
Updated
9 Oct 2026
Live

Installation

  1. Run this in a project set up with shadcn/ui:

    $ pnpm dlx shadcn@latest add https://uxelith.com/r/copy-button.json
  2. It adds these packages for you:

    clsxtailwind-mergemotion
  3. The source lands in your components folder as copy-button.tsx. It's yours: change anything.

Usage

example.tsx
import { CopyButton } from "@/components/copy-button"

export function InstallCommand() {
  return <CopyButton value="npm i uxelith" label="Copy install command" />
}

Source

One file, 156 lines, nothing hidden. Read it before you install it.

copy-button.tsx
"use client";

import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import * as React from "react";

import { cn } from "@/lib/utils";

export interface CopyButtonProps extends Omit<React.ComponentProps<"button">, "value" | "children"> {
  /** Text to put on the clipboard. */
  value: string;
  /** Accessible name and tooltip, e.g. "Copy install command". */
  label?: string;
  /** Show the label as text next to the icon; it reads "Copied" for a moment after copying. */
  showLabel?: boolean;
  /** Milliseconds the "copied" state lasts. */
  timeout?: number;
  /** Called after a successful copy. */
  onCopied?: (value: string) => void;
}

// Motion presets (ADR-010).
const SPRING_SNAPPY = { type: "spring", visualDuration: 0.2, bounce: 0 } as const;
const SPRING_BOUNCY = { type: "spring", visualDuration: 0.45, bounce: 0.35 } as const;

const icons = {
  idle: (
    <>
      <rect x="5.25" y="5.25" width="8.5" height="8.5" rx="1.75" />
      <path d="M10.75 2.75H4.25a1.5 1.5 0 0 0-1.5 1.5v6.5" />
    </>
  ),
  copied: <path d="m3.5 8.5 3 3 6-7" />,
  failed: <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" />,
};

/**
 * A button that copies text: the icon morphs into a tick (or a cross that
 * shakes, if the copy failed), and a visible label reads "Copied".
 *
 * - The result is announced ("Copied" or "Couldn't copy") through a polite
 *   live region, not just shown.
 * - Falls back to a hidden textarea where the Clipboard API isn't available
 *   (older browsers, non-HTTPS pages).
 * - Under reduced motion the icons and label simply swap.
 */
export function CopyButton({
  value,
  label = "Copy",
  showLabel = false,
  timeout = 1800,
  onCopied,
  className,
  onClick,
  ...props
}: CopyButtonProps) {
  const [state, setState] = React.useState<"idle" | "copied" | "failed">("idle");
  const [attempt, setAttempt] = React.useState(0);
  const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);
  const reduceMotion = useReducedMotion();
  React.useEffect(() => () => clearTimeout(timer.current), []);

  const copy = async () => {
    let ok = false;
    try {
      await navigator.clipboard.writeText(value);
      ok = true;
    } catch {
      const area = Object.assign(document.createElement("textarea"), { value, readOnly: true });
      area.style.cssText = "position:fixed;opacity:0;pointer-events:none";
      document.body.append(area);
      area.select();
      try {
        ok = document.execCommand("copy");
      } catch {
        ok = false;
      }
      area.remove();
    }
    setState(ok ? "copied" : "failed");
    setAttempt((n) => n + 1);
    if (ok) onCopied?.(value);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setState("idle"), timeout);
  };

  const visibleLabel = state === "copied" ? "Copied" : state === "failed" ? "Try again" : label;

  return (
    <button
      type="button"
      title={showLabel ? undefined : label}
      onClick={(event) => {
        onClick?.(event);
        if (!event.defaultPrevented) void copy();
      }}
      className={cn(
        "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md text-sm text-muted-foreground outline-none",
        "transition-[color,background-color,scale] duration-150 active:scale-[0.96] motion-reduce:active:scale-100",
        "hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring",
        showLabel ? "px-2.5" : "w-8",
        state === "copied" && "text-[var(--copy-success,var(--primary))] hover:text-[var(--copy-success,var(--primary))]",
        state === "failed" && "text-destructive hover:text-destructive",
        className,
      )}
      {...props}
    >
      <motion.span
        key={state === "failed" ? `failed-${attempt}` : "icon"}
        aria-hidden="true"
        initial={false}
        animate={state === "failed" && !reduceMotion ? { x: [0, -3, 3, -2, 2, 0] } : { x: 0 }}
        transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
        className="relative grid size-4 place-items-center"
      >
        <AnimatePresence initial={false} mode="popLayout">
          <motion.svg
            key={state}
            viewBox="0 0 16 16"
            fill="none"
            stroke="currentColor"
            strokeWidth={state === "idle" ? 1.5 : 1.75}
            strokeLinecap="round"
            strokeLinejoin="round"
            className="size-4"
            initial={{ opacity: 0, scale: 0.4, filter: "blur(3px)" }}
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, scale: 0.4, filter: "blur(3px)" }}
            transition={reduceMotion ? { duration: 0 } : state === "copied" ? SPRING_BOUNCY : SPRING_SNAPPY}
          >
            {icons[state]}
          </motion.svg>
        </AnimatePresence>
      </motion.span>
      {showLabel ? (
        <span aria-hidden="true" className="relative">
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span
              key={visibleLabel}
              className="inline-block"
              initial={{ opacity: 0, y: 6, filter: "blur(2px)" }}
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={{ opacity: 0, y: -6, filter: "blur(2px)" }}
              transition={reduceMotion ? { duration: 0 } : SPRING_SNAPPY}
            >
              {visibleLabel}
            </motion.span>
          </AnimatePresence>
        </span>
      ) : null}
      <span className="sr-only">{label}</span>
      <span role="status" className="sr-only">
        {state === "copied" ? "Copied" : state === "failed" ? "Couldn't copy" : ""}
      </span>
    </button>
  );
}

Props

PropTypeDefaultDescription
valuestring—Text to copy.
labelstring"Copy"Accessible name and tooltip.
showLabelbooleanfalseShow the label as text; it reads "Copied" for a moment after copying.
timeoutnumber1800Milliseconds the copied state lasts.
onCopied(value: string) => void—Called after a successful copy.
--copy-successCSS variable--primaryColour of the tick.

Accessibility

  • Announces "Copied" or "Couldn't copy" in a polite live region.
  • Icon-only by default, always with an accessible name; the name stays the same while the visible label changes.
  • Icons simply swap under prefers-reduced-motion.

Changelog

  1. v1.0.0Latest

    First release.

More in Actions

All 4 in Actions
  • ActionsFree

    Gradient Button

    A call-to-action button with a tasteful gradient: neighbouring hues, a light that follows the pointer and a sheen that passes now and then. The outline style is quiet until a beam of light runs round its border.

  • ActionsFree

    Magnetic Button

    A button with a gentle magnetic field: it leans toward the pointer as you come near, a few pixels at most, its label following a little further for depth, and settles back without a wobble.

  • ActionsFree

    Tooltip

    A small hint that springs out of the side facing its element on hover or keyboard focus. Group a toolbar's tooltips and neighbours open at once while one bubble glides between them. Dismissable and hoverable (WCAG 1.4.13).