Components

Getting started

Components117

More

One-click clipboard copy action with checkmark icon morph, label crossfade, and self-reverting timer.

Used forCode blocksShare links & invite codesAPI keys & secrets

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/copy-button.json

Where it’s used

  • Code blocks

    Top-right copy button in syntax-highlighted code specimens.

  • Share links & invite codes

    One-tap copy for URLs and referral tokens.

  • API keys & secrets

    Fast copying of sensitive tokens with visual confirmation.

What it handles for you

Copying to clipboard is a standard action that often gives weak or delayed feedback. This button gives immediate tactile response with icon morphing and seamless revert.

Instead of wiring upcustom clipboard handlersstatic copy buttonsclunky alert popups
  1. Checkmark morph

    The copy icon crossfades smoothly into a green checkmark on successful copy.

  2. Optional label crossfade

    Supports both compact icon-only mode and text-labeled mode ("Copy" → "Copied!").

  3. Self-reverting timer

    Automatically resets back to idle after a configurable timeout (default 2000ms).

  4. Accessible announcement

    Screen readers hear "Copied!" via an aria-live polite status region.

Overview

A micro-action button engineered for copying code snippets, markdown blocks, URLs, and API keys. On click, the copy icon smoothly morphs into an animated checkmark, optional labels crossfade without layout jumps, and an accessible screen-reader announcement is dispatched.

States

idle
Copy icon with title tooltip.
copied
Emerald checkmark morph with "Copied!" feedback and status announcement.

Usage

usage.tsx
import { CopyButton } from "@/components/ui/copy-button";<CopyButton text="npm i @hairline/button" /><CopyButton text="https://hairlineui.com" showLabel />

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Props

PropTypeDescription
textstringThe string to copy to the user’s clipboard.
labelstringdefault "Copy"Text shown when idle.
copiedLabelstringdefault "Copied!"Text shown when successfully copied.
showLabelbooleandefault falseWhether to show the text label beside the icon.
timeoutMsnumberdefault 2000Duration in milliseconds before reverting back to idle.
variant"secondary" | "outline" | "ghost"default "ghost"Visual appearance.
size"xs" | "sm" | "md"default "sm"Height and icon scale.

Accessibility and motion

  • Uses navigator.clipboard with fallback for older browsers or non-secure contexts.
  • Screen readers announce "Copied!" automatically via aria-live.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/copy-button.tsx
"use client";import * as React from "react";import { Copy } from "lucide-react";import { EASE_OUT, EASE_SETTLE, INTERACTION_FOCUS } from "./ui-interaction-motion";export interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {  /** The text string to copy to the clipboard. */  text: string;  /** Label shown when idle. */  label?: string;  /** Label shown when copied successfully. */  copiedLabel?: string;  /** Whether to show text label beside the icon. Defaults to false (icon-only). */  showLabel?: boolean;  /** Milliseconds before reverting back to idle. Default 2000ms. */  timeoutMs?: number;  variant?: "secondary" | "outline" | "ghost";  size?: "xs" | "sm" | "md";  onCopySuccess?: (text: string) => void;  onCopyError?: (error: unknown) => void;}const sizeStyles = {  xs: {    btn: "h-6 text-[11px] rounded-full gap-1.5 px-2.5",    iconOnly: "size-6 p-0 rounded-full",    icon: 11,  },  sm: {    btn: "h-7 text-[12px] rounded-full gap-1.5 px-3",    iconOnly: "size-7 p-0 rounded-full",    icon: 13,  },  md: {    btn: "h-9 text-[13px] rounded-full gap-2 px-4",    iconOnly: "size-9 p-0 rounded-full",    icon: 15,  },};const variantStyles = {  secondary:    "bg-card text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent active:scale-[0.97]",  outline:    "bg-transparent text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent active:scale-[0.97]",  ghost:    "bg-transparent text-muted-foreground hover:bg-accent hover:text-foreground active:scale-[0.97]",};function AnimatedCheck({ drawn, size }: { drawn: boolean; size: number }) {  return (    <svg      width={size}      height={size}      viewBox="0 0 16 16"      fill="none"      className="text-emerald-600 dark:text-emerald-400 shrink-0"    >      <path        d="M3.5 8.5 6.5 11.5 12.5 4.5"        stroke="currentColor"        strokeWidth="2"        strokeLinecap="round"        strokeLinejoin="round"        pathLength={1}        strokeDasharray={1}        style={{          strokeDashoffset: drawn ? 0 : 1,          transition: drawn ? `stroke-dashoffset 300ms ${EASE_SETTLE} 40ms` : "none",        }}      />    </svg>  );}export function CopyButton({  text,  label = "Copy",  copiedLabel = "Copied!",  showLabel = false,  timeoutMs = 2000,  variant = "ghost",  size = "sm",  onCopySuccess,  onCopyError,  className = "",  disabled,  onClick,  ...props}: CopyButtonProps) {  const [copied, setCopied] = React.useState(false);  const [pressed, setPressed] = React.useState(false);  const timerRef = React.useRef<number | null>(null);  React.useEffect(() => {    return () => {      if (timerRef.current) window.clearTimeout(timerRef.current);    };  }, []);  const handleCopy = async (e: React.MouseEvent<HTMLButtonElement>) => {    onClick?.(e);    if (disabled || !text) return;    try {      if (navigator?.clipboard?.writeText) {        await navigator.clipboard.writeText(text);      } else {        // Fallback for non-secure contexts        const textarea = document.createElement("textarea");        textarea.value = text;        textarea.style.position = "fixed";        textarea.style.opacity = "0";        document.body.appendChild(textarea);        textarea.select();        document.execCommand("copy");        document.body.removeChild(textarea);      }      setCopied(true);      onCopySuccess?.(text);      if (timerRef.current) window.clearTimeout(timerRef.current);      timerRef.current = window.setTimeout(() => {        setCopied(false);      }, timeoutMs);    } catch (err) {      onCopyError?.(err);    }  };  const s = sizeStyles[size];  const v = variantStyles[variant];  return (    <button      type="button"      disabled={disabled}      onClick={handleCopy}      aria-label={copied ? copiedLabel : label}      title={showLabel ? undefined : copied ? copiedLabel : label}      onPointerDown={() => !disabled && setPressed(true)}      onPointerUp={() => setPressed(false)}      onPointerLeave={() => setPressed(false)}      style={{        transform: pressed ? "scale(0.95)" : undefined,        transition: `transform 160ms ${EASE_SETTLE}, opacity 160ms ${EASE_OUT}, background-color 160ms ${EASE_OUT}`,      }}      className={`relative inline-flex items-center justify-center font-medium select-none cursor-pointer rounded-full transition-colors motion-reduce:transition-none disabled:cursor-not-allowed disabled:opacity-40 shrink-0 ${INTERACTION_FOCUS} ${v} ${        showLabel ? s.btn : s.iconOnly      } ${className}`}      {...props}    >      <span className="relative inline-flex items-center justify-center size-full shrink-0">        {/* Checkmark icon with scale and opacity transition */}        <span          aria-hidden="true"          className="absolute inset-0 flex items-center justify-center transition-all duration-200 motion-reduce:transition-none"          style={{            opacity: copied ? 1 : 0,            transform: copied ? "scale(1)" : "scale(0.5)",            filter: copied ? "blur(0px)" : "blur(2px)",            transitionTimingFunction: EASE_SETTLE,          }}        >          <AnimatedCheck drawn={copied} size={s.icon} />        </span>        {/* Copy icon with scale and opacity transition */}        <span          aria-hidden="true"          className="flex items-center justify-center transition-all duration-200 motion-reduce:transition-none text-muted-foreground"          style={{            opacity: copied ? 0 : 1,            transform: copied ? "scale(0.5)" : "scale(1)",            filter: copied ? "blur(2px)" : "blur(0px)",            transitionTimingFunction: EASE_SETTLE,          }}        >          <Copy size={s.icon} strokeWidth={1.75} />        </span>      </span>      {/* Optional Text Label */}      {showLabel && (        <span className="inline-block truncate font-medium ml-1.5">          {copied ? copiedLabel : label}        </span>      )}      {/* Screen Reader live region announcement outside visual flow */}      <span role="status" aria-live="polite" className="sr-only">        {copied ? copiedLabel : ""}      </span>    </button>  );}export default CopyButton;

Registry item: /r/copy-button.json