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
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | The text written to the clipboard. | |
| label | string | "Copy" | Accessible name when there is no visible label. |
| timeout | number | 2000 | How long the check mark stays, in milliseconds. |
| variant | Button variant | "outline" | Any shadcn button variant. |
| children | ReactNode | Optional 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