Copy button
Copies a value with one click, the icon turns into a check and a live region announces it.
One of 6 components in Buttons
Details
What Copy button brings along, read from the registry and the source.
- Category
- Buttons
- Area
- Elements
- Variants
- 2
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/copy-button.tsx
- Brings along
- utilskiticonicon-button
- npm packages
- None
- Languages
- English and German, 4 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CopyButtonProps interface from components/ui/copy-button.tsx, with the comments from the code.
valuerequiredstringThe text that lands in the clipboard
displayoptional'icon' | 'label'icon: square icon button. label: icon and text ("Copy" turns into "Copied").
variantoptionalIconButtonVariantsizeoptionalIconButtonSizeonCopyoptional() => void
Built-in texts
Copy button writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 4 texts
| Key | English | German |
|---|---|---|
| copy | English Copy | German Kopieren |
| copied | English Copied | German Kopiert |
| failed | English Copy failed | German Kopieren fehlgeschlagen |
| copyValue | English Copy to clipboard | German In die Zwischenablage kopieren |
Accessibility
Read from the source of the component.
- Sets aria-label and aria-live, so screen readers announce purpose and state.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 217 lines
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { iconButtonClass, iconPress, type IconButtonSize, type IconButtonVariant } from '@/components/ui/icon-button';
const TEXTS = {
en: { copy: 'Copy', copied: 'Copied', failed: 'Copy failed', copyValue: 'Copy to clipboard' },
de: { copy: 'Kopieren', copied: 'Kopiert', failed: 'Kopieren fehlgeschlagen', copyValue: 'In die Zwischenablage kopieren' },
};
type Texts = (typeof TEXTS)['en'];
async function writeClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Older browsers and insecure origins
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none';
document.body.append(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok;
}
}
/** Copies a text and reports back. state: idle, copied, failed. */
export function useCopy(timeout = 1800) {
const [state, setState] = useState<'idle' | 'copied' | 'failed'>('idle');
const timer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(timer.current), []);
const copy = async (text: string) => {
const ok = await writeClipboard(text);
setState(ok ? 'copied' : 'failed');
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setState('idle'), timeout);
};
return [state, copy] as const;
}
/**
* Copy and check on top of each other. The copy icon turns away and shrinks,
* the check draws itself (stroke-dashoffset), the pair settles with a small pop.
*/
function SwapIcon({ done, size }: { done: boolean; size: number }) {
const ref = useRef<HTMLSpanElement>(null);
const was = useRef(done);
useEffect(() => {
if (was.current === done) return;
was.current = done;
const el = ref.current;
if (!done || !el || prefersReducedMotion() || typeof el.animate !== 'function') return;
el.animate([{ transform: 'scale(1)' }, { transform: 'scale(0.82)', offset: 0.3 }, { transform: 'scale(1.1)', offset: 0.7 }, { transform: 'scale(1)' }], { duration: 460, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}, [done]);
return (
<span ref={ref} className="relative grid shrink-0 place-items-center" style={{ width: size, height: size }}>
<Icon
name="copy"
size={size}
className={cn('absolute transition-[opacity,scale,rotate] duration-200 ease-[var(--ease-kit)] motion-reduce:transition-none', done && '-rotate-12 scale-50 opacity-0')}
/>
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" aria-hidden className={cn('absolute text-success transition-opacity duration-200', !done && 'opacity-0')}>
<path
d="m5 12.5 4.5 4.5L19 7.5"
pathLength={1}
strokeDasharray="1 1"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn('transition-[stroke-dashoffset] ease-[var(--ease-kit)] motion-reduce:transition-none', done ? 'duration-[420ms] delay-75 [stroke-dashoffset:0]' : 'duration-150 [stroke-dashoffset:1]')}
/>
</svg>
</span>
);
}
/** Two words on one spot: the old one leaves upward, the new one comes up from below, the width of both stays reserved */
function SwapText({ done, idle, active, invisible }: { done: boolean; idle: string; active: string; invisible?: boolean }) {
const base = 'col-start-1 row-start-1 transition-[opacity,translate] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none';
return (
<span className="grid overflow-hidden">
<span className={cn(base, done && '-translate-y-2 opacity-0', invisible && done && 'invisible')}>{idle}</span>
<span aria-hidden className={cn(base, !done && 'translate-y-2 opacity-0', invisible && !done && 'invisible')}>
{active}
</span>
</span>
);
}
export interface CopyButtonProps {
/** The text that lands in the clipboard */
value: string;
/** icon: square icon button. label: icon and text ("Copy" turns into "Copied"). */
display?: 'icon' | 'label';
variant?: IconButtonVariant;
size?: IconButtonSize;
labels?: Partial<Texts>;
onCopy?: () => void;
className?: string;
}
/**
* Copies a value with one click. The icon turns into a check, a polite live region announces it.
* <CopyButton value="npm i @northwind/sdk" />
*/
export function CopyButton({ value, display = 'icon', variant = 'ghost', size = 'sm', labels, onCopy, className }: CopyButtonProps) {
const t = useTexts(TEXTS, labels);
const [state, copy] = useCopy();
const done = state === 'copied';
const iconSize = size === 'xs' ? 14 : size === 'sm' ? 16 : 18;
const status = <span aria-live="polite" className="sr-only">{state === 'copied' ? t.copied : state === 'failed' ? t.failed : ''}</span>;
const click = () => {
copy(value);
onCopy?.();
};
if (display === 'label') {
return (
<button
type="button"
onClick={click}
onPointerDown={iconPress}
className={cn(
iconButtonClass({ variant, size }),
'inline-flex w-auto gap-1.5 font-medium',
size === 'xs' ? 'h-7 px-2 text-xs' : size === 'sm' ? 'h-9 px-3 text-[13px]' : 'h-11 px-4 text-sm',
className,
)}
>
<SwapIcon done={done} size={iconSize} />
{/* Both words reserve their width, so the button never jumps */}
<SwapText done={done} idle={t.copy} active={t.copied} />
{status}
</button>
);
}
return (
<button type="button" onClick={click} onPointerDown={iconPress} aria-label={done ? t.copied : t.copyValue} title={t.copy} className={iconButtonClass({ variant, size, className })}>
<SwapIcon done={done} size={iconSize} />
{status}
</button>
);
}
export interface CopyFieldProps {
value: string;
/** Visible label above the field */
label?: string;
/** Shown before the value, e.g. "$" for a command */
prompt?: string;
/** Masks the value until revealed, e.g. for API keys */
secret?: boolean;
labels?: Partial<Texts>;
className?: string;
}
/** A read-only value with a copy button: invite links, API keys, install commands. */
export function CopyField({ value, label, prompt, secret, labels, className }: CopyFieldProps) {
const t = useTexts(TEXTS, labels);
const [state, copy] = useCopy();
const done = state === 'copied';
const valueRef = useRef<HTMLSpanElement>(null);
// A light passes over the value, so you see what went to the clipboard
const sweep = () => {
const el = valueRef.current;
if (!el || prefersReducedMotion() || typeof el.animate !== 'function') return;
const band = document.createElement('span');
band.setAttribute('aria-hidden', 'true');
band.style.cssText = 'position:absolute;inset:0;pointer-events:none;background:linear-gradient(100deg,transparent 20%,color-mix(in oklab, var(--success) 22%, transparent) 50%,transparent 80%);';
el.append(band);
const a = band.animate([{ transform: 'translateX(-100%)', opacity: 1 }, { transform: 'translateX(100%)', opacity: 0.6 }], { duration: 640, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
a.onfinish = a.oncancel = () => band.remove();
};
const shown = secret ? value.slice(0, 7) + '•'.repeat(Math.max(8, Math.min(20, value.length - 11))) + value.slice(-4) : value;
return (
<div className={cn('grid w-full min-w-0 gap-2', className)}>
{label && <p className="text-sm font-medium text-foreground">{label}</p>}
{/* The row measures itself: in a narrow spot (phone, popover) the button folds to its icon and the value keeps the room */}
<div
data-done={done || undefined}
className="@container/copy flex h-11 min-w-[12rem] items-center rounded-lg border border-border bg-surface ps-3.5 pe-1.5 shadow-[var(--highlight)] transition-[border-color,box-shadow] duration-300 data-done:border-[color-mix(in_oklab,var(--success)_45%,var(--border))] data-done:shadow-[0_0_0_3px_color-mix(in_oklab,var(--success)_12%,transparent),var(--highlight)]"
>
{prompt && <span className="me-2 select-none text-subtle-foreground">{prompt}</span>}
<span ref={valueRef} className="relative min-w-0 flex-1 truncate text-sm tabular-nums text-foreground" aria-label={secret ? label : undefined}>
{shown}
</span>
<button
type="button"
onClick={() => {
copy(value);
sweep();
}}
onPointerDown={iconPress}
aria-label={done ? t.copied : label ? `${t.copy}: ${label}` : t.copy}
className={cn(
'relative isolate ms-2 inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md border border-border bg-background px-2.5 text-xs font-medium text-foreground shadow-[var(--shadow-sm)] transition-[background-color,scale] duration-200 hover:bg-accent active:scale-95 active:duration-100 motion-reduce:active:scale-100 @max-[22rem]/copy:w-8 @max-[22rem]/copy:px-0',
'after:absolute after:-inset-1.5 after:content-[""]',
)}
>
<SwapIcon done={done} size={14} />
<span aria-hidden className="@max-[22rem]/copy:hidden">
<SwapText done={done} idle={t.copy} active={t.copied} invisible />
</span>
<span aria-live="polite" className="sr-only">
{done ? t.copied : ''}
</span>
</button>
</div>
</div>
);
}Frequent questions
How do I install Copy button?
With aiw add @ai-website/copy-button. The command puts components/ui/copy-button.tsx into your project, the code is yours from then on. It brings along icon and icon-button. It needs no further npm packages.
Which variants does Copy button have?
2 variants, Buttons and Copy fields.
Can I translate the texts of Copy button?
Yes. Copy button carries 4 texts of its own in English and German, such as “Copy”, “Copied” and “Copy failed”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Copy button?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Copy button with aiw_components and install it with aiw_add @ai-website/copy-button. Name the variant in your prompt, for example variant="outline", and the assistant writes your content straight into the props.