Skip to content
Corsair UI
Browse the docs

ComponentsNo. 20 of 109

Copy Button

An icon button that copies a value and swaps to a check mark, announcing it to screen readers.

Installation

pnpm dlx shadcn@latest add @corsair-ui/copy-button

No setup needed: the CLI finds @corsair-ui in the shadcn registry directory. The files land in your project as source: edit them like your own code. More in Installation.

Also adds
utilsbutton
npm
@radix-ui/react-slotclass-variance-authorityclsxlucide-reacttailwind-merge

Examples

Sizes and outline

Small, default and large ghost buttons, then the outline variant.

In an input group

API

PropTypeDefault
value

The text that lands on the clipboard.

string—
size

Square icon sizes.

"sm" | "default" | "lg""default"
timeout

Ms the check stays.

number1600
onCopied

Only after the clipboard accepted it.

(value) => void—
label / copiedLabel

Name, and what is announced.

string"Copy to clipboard" / "Copied"

Accessibility

  • "Copied" is announced through a live region, and never claimed when the browser refused.
  • It stays enabled while it shows the check, so keyboard focus is never dropped.

Compatibility

Tailwind CSS
3.4 and 4, both checked in CI
React
19
Rendering
Client component ("use client")

Source

1 file, installed as source you own
copy-button.tsxView on GitHub
"use client";

import { CheckIcon, CopyIcon } from "lucide-react";
import { useEffect, useRef, useState, type ComponentProps, type MouseEvent } from "react";

import { cn } from "@/registry/default/lib/utils";
import { Button } from "@/registry/default/ui/button";

const SIZES = { sm: "icon-sm", default: "icon", lg: "icon-lg" } as const;

interface CopyButtonProps extends Omit<
  ComponentProps<typeof Button>,
  "value" | "size" | "children" | "asChild" | "loading"
> {
  /** The text that lands on the clipboard. */
  value: string;
  size?: keyof typeof SIZES;
  /** How long the check mark stays before the button resets, in ms. */
  timeout?: number;
  /** Called after the text was copied, not when the browser refused. */
  onCopied?: (value: string) => void;
  /** Accessible name of the button. */
  label?: string;
  /** Announced to screen readers once the text is on the clipboard. */
  copiedLabel?: string;
}

/**
 * An icon button that copies `value` and swaps its icon for a check mark.
 * "Copied" is announced through a live region and only claimed when the
 * clipboard accepted the text. The button stays enabled while it shows the
 * check, so keyboard focus is never dropped.
 *
 * @example
 * <CopyButton value="pnpm dlx shadcn@latest add @corsair-ui/copy-button" />
 */
function CopyButton({
  value,
  size = "default",
  timeout = 1600,
  onCopied,
  label = "Copy to clipboard",
  copiedLabel = "Copied",
  variant = "ghost",
  className,
  onClick,
  ...props
}: CopyButtonProps) {
  const [copied, setCopied] = useState(false);
  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);

  useEffect(() => () => clearTimeout(timer.current), []);

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(value);
    } catch {
      // Refused, or not a secure context: nothing was copied, so claim nothing.
      return;
    }
    setCopied(true);
    onCopied?.(value);
    clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(false), timeout);
  };

  const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
    onClick?.(event);
    if (!event.defaultPrevented) void copy();
  };

  const icon =
    "col-start-1 row-start-1 motion-safe:transition-[opacity,transform,filter] motion-safe:duration-200 motion-safe:ease-out";

  return (
    <>
      <Button
        type="button"
        data-slot="copy-button"
        data-state={copied ? "copied" : "idle"}
        variant={variant}
        size={SIZES[size]}
        aria-label={label}
        className={cn("grid place-items-center active:scale-95", className)}
        onClick={handleClick}
        {...props}
      >
        <CheckIcon
          aria-hidden="true"
          className={cn(icon, copied ? "scale-100 opacity-100" : "scale-50 opacity-0 blur-[2px]")}
        />
        <CopyIcon
          aria-hidden="true"
          className={cn(icon, copied ? "scale-50 opacity-0 blur-[2px]" : "scale-100 opacity-100")}
        />
      </Button>
      <span role="status" aria-live="polite" className="sr-only">
        {copied ? copiedLabel : ""}
      </span>
    </>
  );
}

export { CopyButton, type CopyButtonProps };