Copy Button
Click-to-copy button with copied feedback, icon-only or labelled, ghost / outline variants and a non-secure-context clipboard fallback.
Example
Installation
npx shadcn@latest add https://hirael.com/r/copy-button.jsonUsage
import { CopyButton } from "@/components/copy-button"Paths assume the default @/ alias from your components.json. The example above shows the parts composed.
API
<CopyButton />
+ native element props| Prop | Type | Default |
|---|---|---|
value*Text written to the clipboard on click. | string | none |
variant | "ghost" | "outline" | 'ghost' |
size | "sm" | "md" | 'md' |
timeoutHow long the copied state stays, in ms. | number | 1500 |
onCopy | ((value: string) => void) | none |
Component source
'use client';
import * as React from 'react';
import { Check, Copy } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
export interface CopyButtonProps extends Omit<React.ComponentProps<'button'>, 'value' | 'onCopy'> {
/** Text written to the clipboard on click. */
value: string;
variant?: 'ghost' | 'outline';
size?: 'sm' | 'md';
/** How long the copied state stays, in ms. */
timeout?: number;
onCopy?: (value: string) => void;
}
const writeClipboard = async (text: string) => {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
// Fallback for non-secure contexts where the async clipboard API is absent.
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
};
const CopyButton = ({
value,
variant = 'ghost',
size = 'md',
timeout = 1500,
onCopy,
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 handleCopy = async () => {
try {
await writeClipboard(value);
setCopied(true);
onCopy?.(value);
clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), timeout);
} catch {
setCopied(false);
}
};
const hasLabel = children != null;
return (
<Button
type="button"
variant={variant}
size={hasLabel ? 'sm' : size === 'sm' ? 'icon-xs' : 'icon-sm'}
data-slot="copy-button"
data-state={copied ? 'copied' : 'idle'}
aria-label={copied ? 'Copied' : 'Copy to clipboard'}
onClick={handleCopy}
className={cn(size === 'sm' ? '[&_svg]:size-3.5' : '[&_svg]:size-4', className)}
{...props}
>
<span className="relative inline-flex items-center justify-center">
<Check
aria-hidden
className={cn('transition-all duration-150', copied ? 'scale-100 opacity-100' : 'scale-50 opacity-0')}
/>
<Copy
aria-hidden
className={cn(
'absolute transition-all duration-150',
copied ? 'scale-50 opacity-0' : 'scale-100 opacity-100',
)}
/>
</span>
{hasLabel && <span>{copied ? 'Copied' : children}</span>}
</Button>
);
};
export { CopyButton };
Dependencies
shadcn registry
button
npm
lucide-react