Zum Inhalt springen
UI Kit
Accent

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

2 Variants

Variants

Knöpfe

Als Icon oder mit Text. Das Kopier-Icon dreht weg, der Haken zeichnet sich, die Wörter tauschen nach oben ohne Sprung.

Kopierfelder

Nur lesbare Werte mit Kopierknopf für Links, Schlüssel, Befehle und Bankdaten. Ein Licht läuft über das Kopierte.

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

Props

Die Schnittstelle CopyButtonProps aus components/ui/copy-button.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • valuePflicht
    string

    The text that lands in the clipboard

  • displayoptional
    'icon' | 'label'

    icon: square icon button. label: icon and text ("Copy" turns into "Copied").

  • variantoptional
    IconButtonVariant
  • sizeoptional
    IconButtonSize
  • onCopyoptional
    () => void

Eingebaute Texte

Diese Texte schreibt Kopierknopf selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 4 Texte anzeigen
copyEnglisch CopyDeutsch Kopieren
copiedEnglisch CopiedDeutsch Kopiert
failedEnglisch Copy failedDeutsch Kopieren fehlgeschlagen
copyValueEnglisch Copy to clipboardDeutsch 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
components/ui/copy-button.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert