# Copy Button

A copy-to-clipboard button with success feedback.

## Installation

```bash
npx shadcn@latest add https://mwui.vercel.app/r/copy-button.json
```

[Registry JSON](https://mwui.vercel.app/r/copy-button.json)

## Preview

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

export function Preview() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4 text-sm">
      <div className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2">
        <code className="truncate font-mono text-xs">npx shadcn add copy-button</code>
        <CopyButton value="npx shadcn add copy-button" />
      </div>

      <div className="flex flex-wrap items-center gap-2">
        <CopyButton value="sk_live_51H8xX2" showLabel copyLabel="Copy key" variant="outline" />
        <CopyButton
          value={() =>
            new Promise<string>((resolve) => {
              setTimeout(() => resolve("deferred value"), 400);
            })
          }
          showLabel
          copyLabel="Copy deferred"
          variant="outline"
        />
      </div>
    </div>
  );
}
```


## Source

### ui/copy-button.tsx

```tsx
"use client";

import { IconCheck, IconCopy } from "@tabler/icons-react";
import * as React from "react";

import { Button } from "@/components/ui/button";
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
import { cn } from "@/lib/utils";

type CopyButtonClickEvent = Parameters<
  NonNullable<React.ComponentProps<typeof Button>["onClick"]>
>[0];

type CopyButtonProps = Omit<React.ComponentProps<typeof Button>, "children" | "value"> & {
  value: string | (() => string | Promise<string>);
  showLabel?: boolean;
  copyLabel?: string;
  copiedLabel?: string;
  errorLabel?: string;
  resetDelay?: number;
  onCopied?: (value: string) => void;
  onCopyError?: (error: Error) => void;
};

function CopyButton({
  value,
  showLabel = false,
  copyLabel = "Copy",
  copiedLabel = "Copied",
  errorLabel = "Copy failed",
  resetDelay = 2000,
  variant = "ghost",
  size,
  className,
  disabled,
  onClick,
  onCopied,
  onCopyError,
  ...props
}: CopyButtonProps) {
  const { status, error, copy, reset } = useCopyToClipboard();
  const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);

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

  const handleClick = async (event: CopyButtonClickEvent) => {
    onClick?.(event);
    if (event.defaultPrevented || disabled || status === "copied") return;

    const text = typeof value === "function" ? await value() : value;
    const copied = await copy(text);

    clearTimeout(timeoutRef.current);
    timeoutRef.current = setTimeout(reset, resetDelay);

    if (copied) {
      onCopied?.(text);
    } else if (error) {
      onCopyError?.(error);
    }
  };

  const label = status === "copied" ? copiedLabel : status === "error" ? errorLabel : copyLabel;

  return (
    <Button
      type="button"
      data-slot="copy-button"
      data-status={status}
      variant={status === "error" ? "destructive" : variant}
      size={size ?? (showLabel ? "sm" : "icon-sm")}
      disabled={disabled}
      aria-label={showLabel ? undefined : label}
      className={cn("shrink-0", status === "copied" && "cursor-default", className)}
      onClick={(event) => {
        void handleClick(event);
      }}
      {...props}
    >
      {status === "copied" ? (
        <IconCheck aria-hidden="true" data-icon={showLabel ? "inline-start" : undefined} />
      ) : (
        <IconCopy aria-hidden="true" data-icon={showLabel ? "inline-start" : undefined} />
      )}

      {showLabel ? <span>{label}</span> : null}

      {}
      <span aria-live="polite" className="sr-only">
        {status === "idle" ? "" : label}
      </span>
    </Button>
  );
}

export { CopyButton, type CopyButtonProps };
```



## Usage

Copy something, show that it worked, go back to normal. Small enough to keep rewriting, which is
exactly why it should be installed once.

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

<CopyButton value="npx shadcn add copy-button" />;
```

The icon swaps to a check for two seconds, then returns. If the clipboard write fails — an insecure
origin, a denied permission, an old browser — the button turns destructive and says so, instead of
pretending the copy happened.

## Deferred values

Pass a function when the value is expensive or only known at click time, such as a token you would
rather not hold in state:

```tsx
<CopyButton value={() => generateApiKey()} showLabel copyLabel="Copy key" />
```

It may return a promise; the success state waits for it.

## Props

| Prop           | Description                                                          |
| -------------- | -------------------------------------------------------------------- |
| `value`        | The text, or a function resolving to it.                              |
| `showLabel`    | Render the label next to the icon. Icon-only by default.              |
| `copyLabel` / `copiedLabel` / `errorLabel` | Override the three states.                |
| `resetDelay`   | How long the success state lasts. Defaults to `2000`.                 |
| `onCopied` / `onCopyError` | Side effects — analytics, a toast.                        |

Button props pass through, so `variant`, `size`, and `className` behave as usual.

## Accessibility

Icon-only buttons take their accessible name from the current state, so the button is "Copy" before
and "Copied" after. The state change is also written into a visually hidden `aria-live` region —
without it the swap from one icon to another is silent, and a screen reader user has no way to know
the copy succeeded.

