Forms

Copy Button.

An icon button that copies a value to the clipboard and flashes a green check. Failures show a red cross instead of throwing.

Installation#

With the CLI

$npx lanterncn add copy-button

This also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/copy-button. See Installation if your project is not set up yet.

Manually

  1. Install the dependencies

    npm install class-variance-authority lucide-react
  2. Copy the source into your project

    components/ui/copy-button.tsx
    "use client";
    
    import * as React from "react";
    import { type VariantProps } from "class-variance-authority";
    import { CheckIcon, CopyIcon, XIcon } from "lucide-react";
    
    import { cn } from "@/lib/utils";
    import { buttonVariants } from "@/components/ui/button";
    
    type CopyState = "idle" | "copied" | "error";
    
    async function writeClipboard(text: string) {
      if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
        try {
          await navigator.clipboard.writeText(text);
          return;
        } catch {
          // Permission denied or unfocused document: try the legacy path below.
        }
      }
      // Fallback for insecure origins, embedded frames and older browsers.
      const area = document.createElement("textarea");
      area.value = text;
      area.setAttribute("readonly", "");
      area.style.position = "fixed";
      area.style.opacity = "0";
      document.body.appendChild(area);
      area.select();
      const ok = document.execCommand("copy");
      document.body.removeChild(area);
      if (!ok) throw new Error("Copy command failed");
    }
    
    /** An icon button that copies `value` and flashes a green check. */
    function CopyButton({
      className,
      value,
      variant = "ghost",
      size = "icon-sm",
      label = "Copy",
      timeout = 1500,
      onCopy,
      onCopyError,
      onClick,
      children,
      ...props
    }: Omit<React.ComponentProps<"button">, "value"> &
      VariantProps<typeof buttonVariants> & {
        value: string;
        /** Accessible name before copying. */
        label?: string;
        /** How long the copied state lasts, in ms. */
        timeout?: number;
        onCopy?: (value: string) => void;
        onCopyError?: (error: unknown) => void;
      }) {
      const [state, setState] = React.useState<CopyState>("idle");
      const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined);
    
      React.useEffect(() => () => clearTimeout(timer.current), []);
    
      async function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
        onClick?.(event);
        if (event.defaultPrevented) return;
        try {
          await writeClipboard(value);
          setState("copied");
          onCopy?.(value);
        } catch (error) {
          setState("error");
          onCopyError?.(error);
        }
        clearTimeout(timer.current);
        timer.current = setTimeout(() => setState("idle"), timeout);
      }
    
      const Icon = state === "copied" ? CheckIcon : state === "error" ? XIcon : CopyIcon;
      const status = state === "copied" ? "Copied" : state === "error" ? "Copy failed" : "";
    
      return (
        <button
          type="button"
          data-slot="copy-button"
          data-state={state}
          aria-label={children ? undefined : state === "idle" ? label : status}
          className={cn(
            buttonVariants({ variant, size }),
            "data-[state=copied]:text-success data-[state=error]:text-destructive",
            className,
          )}
          onClick={handleClick}
          {...props}
        >
          <Icon key={state} aria-hidden="true" className={cn(state !== "idle" && "animate-in zoom-in-50 fade-in-0 duration-150")} />
          {children}
          <span role="status" aria-live="polite" className="sr-only">
            {status}
          </span>
        </button>
      );
    }
    
    export { CopyButton };
  3. Update the import paths to match your project

    The source imports cn from @/lib/utils and uses button.

Usage#

import { CopyButton } from "@/components/ui/copy-button";
<CopyButton value="pastebin get Xk2p9 hub" label="Copy command" />

Uses the Clipboard API and falls back to a hidden textarea on insecure origins. The result is announced through a polite live region.

Examples#

Next to a read-only field