Kopierknopf
Kopiert einen Wert mit einem Klick, das Icon wird zum Haken und eine Live-Region sagt es an.
Eine von 6 Components in Buttons
Details
Was Kopierknopf mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Buttons
- Bereich
- Elements
- Variants
- 2
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/copy-button.tsx
- Bringt mit
- utilskiticonicon-button
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 4 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CopyButtonProps aus components/ui/copy-button.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
valuePflichtstringThe text that lands in the clipboard
displayoptional'icon' | 'label'icon: square icon button. label: icon and text ("Copy" turns into "Copied").
variantoptionalIconButtonVariantsizeoptionalIconButtonSizeonCopyoptional() => void
Eingebaute Texte
Diese Texte schreibt Kopierknopf selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 4 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| copy | Englisch Copy | Deutsch Kopieren |
| copied | Englisch Copied | Deutsch Kopiert |
| failed | Englisch Copy failed | Deutsch Kopieren fehlgeschlagen |
| copyValue | Englisch Copy to clipboard | Deutsch In die Zwischenablage kopieren |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 4 Stellen im Code schalten Bewegung dann ab.
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 217 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Kopierknopf?
Mit aiw add @ai-website/copy-button. Der Befehl legt die Datei components/ui/copy-button.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und icon-button. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Kopierknopf?
2 Varianten, Knöpfe und Kopierfelder.
Kann ich die Texte von Kopierknopf übersetzen?
Ja. Kopierknopf bringt 4 eigene Texte auf Deutsch und Englisch mit, etwa „Kopieren“, „Kopiert“ und „Kopieren fehlgeschlagen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Kopierknopf einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Kopierknopf mit aiw_components und installieren sie mit aiw_add @ai-website/copy-button. Nenn im Prompt die Variante, etwa variant="outline", dann schreibt der Assistent deine Inhalte direkt in die Props.