Skip to content
goodtabs UI
BrowseBrowse components

Copy button

Copies a value to the clipboard and confirms with a check mark.

npx shadcn@latest add https://ui.goodtabs.co/r/copy-button.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/copy-button

npx shadcn@latest add @goodtabs/copy-button

Sits inside a command field. The check shows for two seconds.

With a label

Pass children to show a label. Any button variant works.

Usage

import { CopyButton } from "@/components/ui/copy-button"

<CopyButton value="npx shadcn@latest add @goodtabs/copy-button" />
<CopyButton value={apiKey} variant="secondary">Copy key</CopyButton>

Props

PropTypeDefaultDescription
valuestringThe text written to the clipboard.
labelstring"Copy"Accessible name when there is no visible label.
timeoutnumber2000How long the check mark stays, in milliseconds.
variantButton variant"outline"Any shadcn button variant.
childrenReactNodeOptional visible label. Switches the size from icon-sm to sm.

Source

copy-button/copy-button.tsx
"use client";

import * as React from "react";
import { CheckIcon, CopyIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type CopyButtonProps = Omit<React.ComponentProps<typeof Button>, "onClick" | "children"> & {
  /** The text written to the clipboard. */
  value: string;
  /** Accessible name. Defaults to "Copy". */
  label?: string;
  /** How long the check mark stays, in ms. */
  timeout?: number;
  /** Show the label next to the icon. */
  children?: React.ReactNode;
};

/**
 * A button that copies text and confirms with a check mark.
 * Icon only by default; pass children to add a label.
 */
export function CopyButton({
  value,
  label = "Copy",
  timeout = 2000,
  variant = "outline",
  size,
  className,
  children,
  ...props
}: CopyButtonProps) {
  const [copied, setCopied] = React.useState(false);
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

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

  const copy = async () => {
    try {
      await navigator.clipboard.writeText(value);
      setCopied(true);
      clearTimeout(timer.current);
      timer.current = setTimeout(() => setCopied(false), timeout);
    } catch {
      // Clipboard can be blocked (insecure context, permissions). Fail quietly: the text is still visible.
    }
  };

  const Icon = copied ? CheckIcon : CopyIcon;

  return (
    <Button
      type="button"
      variant={variant}
      size={size ?? (children ? "sm" : "icon-sm")}
      aria-label={children ? undefined : copied ? "Copied" : label}
      data-copied={copied || undefined}
      onClick={copy}
      className={cn("transition-colors", className)}
      {...props}
    >
      <Icon data-icon={children ? "inline-start" : undefined} aria-hidden="true" />
      {children ? <span>{copied ? "Copied" : children}</span> : null}
      <span className="sr-only" aria-live="polite">
        {copied ? "Copied to clipboard" : ""}
      </span>
    </Button>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/copy-button/prompt.md, or the Copy AI prompt button above.

When to use

  • Next to a command, key, link or id that people need in their clipboard
  • Inside code blocks, install fields and settings rows
  • Anywhere the copied text is visible, so the button can stay icon only

When not to use

  • For copying rich content or files, which the clipboard API handles differently
  • As the only way to get at the text: always show the value too

Agent notes

  • It extends shadcn's Button, so every Button prop and variant applies
  • Without children it renders icon-sm with an aria-label; with children it renders sm with the label and swaps it to Copied
  • Clipboard writes need a secure context (https or localhost); failures are silent by design
  • The check mark state is data-copied on the button, usable for styling