Zum Inhalt springen
UI Kit
Accent

Tag-Eingabe

Mehrere Werte in einem Feld: Enter oder Komma fügt hinzu, zweimal Rücktaste entfernt, eingefügte Listen teilen sich selbst.

Eine von 7 Components in Picker und Auswahlfelder

1 Variant

Variants

Tags

Vorschläge zum Antippen, eine Höchstzahl, eine Prüfung je Tag und ein Fehler als Text. Neue Tags springen hinein, entfernte schrumpfen weg, ein abgelehnter schüttelt das Feld.

Details

Was Tag-Eingabe mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
1
Version
1.0.0 · seit 1.0.0
Datei
components/ui/tag-input.tsx
Bringt mit
utilskiticoninput
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 7 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle TagInputProps aus components/ui/tag-input.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von FieldOwnProps an.

  • valueoptional
    string[]
  • defaultValueoptional
    string[]
  • onValueChangeoptional
    (tags: string[]) => void
  • suggestionsoptional
    string[]

    Quick picks under the field

  • maxoptional
    number
  • validateoptional
    (tag: string) => string | undefined

    Check a new tag, return an error text to refuse it

  • placeholderoptional
    string
  • nameoptional
    string
  • idoptional
    string
  • disabledoptional
    boolean
  • invalidoptional
    boolean

Eingebaute Texte

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

Alle 7 Texte anzeigen
removeEnglisch RemoveDeutsch Entfernen
addedEnglisch addedDeutsch hinzugefügt
removedEnglisch removedDeutsch entfernt
duplicateEnglisch is already in the listDeutsch steht schon in der Liste
limitEnglisch Limit reachedDeutsch Höchstzahl erreicht
suggestionsEnglisch SuggestionsDeutsch Vorschläge
placeholderEnglisch Type and press EnterDeutsch Tippen und Enter drücken

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Tag-Eingabe reagiert selbst auf die Tasten Enter, Rücktaste und Leertaste.
  • Setzt aria-describedby, aria-invalid, 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.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.
  • Ohne Animationen, sie steht sofort ruhig da.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 236 Zeilen
components/ui/tag-input.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { controlBox, sinceAlive, useArrival, useControl, withField, type FieldOwnProps } from '@/components/ui/input';

const TEXTS = {
  en: { remove: 'Remove', added: 'added', removed: 'removed', duplicate: 'is already in the list', limit: 'Limit reached', suggestions: 'Suggestions', placeholder: 'Type and press Enter' },
  de: { remove: 'Entfernen', added: 'hinzugefügt', removed: 'entfernt', duplicate: 'steht schon in der Liste', limit: 'Höchstzahl erreicht', suggestions: 'Vorschläge', placeholder: 'Tippen und Enter drücken' },
};

const EASE = 'cubic-bezier(0.2,0.7,0.2,1)';
const moves = (el: Element | null): el is HTMLElement => Boolean(el) && !prefersReducedMotion() && typeof (el as HTMLElement).animate === 'function';

/** One tag in the field. It pops in when it is added, and shrinks away before it is removed. */
function Chip({ tag, armed, disabled, label, onRemove }: { tag: string; armed: boolean; disabled?: boolean; label: string; onRemove: () => void }) {
  const ref = useRef<HTMLSpanElement>(null);
  useEffect(() => {
    const el = ref.current;
    if (sinceAlive() < 400 || !moves(el)) return;
    el.animate(
      [
        { transform: 'scale(0.6)', opacity: 0 },
        { transform: 'scale(1.07)', opacity: 1, offset: 0.6 },
        { transform: 'scale(1)', opacity: 1 },
      ],
      { duration: 380, easing: EASE },
    );
  }, []);
  return (
    <span
      ref={ref}
      className={cn(
        'inline-flex h-8 max-w-full items-center gap-1 rounded-md border border-border bg-surface-2 ps-2.5 pe-1 text-[13px] font-medium text-foreground transition-[background-color,border-color,box-shadow]',
        armed && 'border-[color-mix(in_oklab,var(--destructive)_50%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_10%,var(--surface-2))]',
      )}
    >
      <span className="truncate">{tag}</span>
      <button
        type="button"
        disabled={disabled}
        aria-label={label}
        onClick={(e) => {
          e.stopPropagation();
          const el = ref.current;
          if (!moves(el)) return onRemove();
          const a = el.animate([{ transform: 'scale(1)', opacity: 1 }, { transform: 'scale(0.6)', opacity: 0 }], { duration: 170, easing: 'cubic-bezier(0.4,0,1,1)', fill: 'forwards' });
          a.onfinish = onRemove;
        }}
        className="relative grid size-6 shrink-0 place-items-center rounded-[5px] text-subtle-foreground transition-[background-color,color,scale] duration-200 after:absolute after:-inset-1.5 after:content-[''] hover:bg-accent hover:text-foreground focus-visible:text-foreground active:scale-90"
      >
        <Icon name="x" size={13} strokeWidth={2} />
      </button>
    </span>
  );
}

function Problem({ id, children }: { id: string; children: React.ReactNode }) {
  const ref = useArrival<HTMLParagraphElement>();
  return (
    <p ref={ref} id={id} className="flex items-start gap-1.5 text-[13px] font-medium leading-5 text-destructive">
      <Icon name="alert-circle" size={15} strokeWidth={1.8} className="mt-[2.5px]" />
      {children}
    </p>
  );
}

export interface TagInputProps extends FieldOwnProps {
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (tags: string[]) => void;
  /** Quick picks under the field */
  suggestions?: string[];
  max?: number;
  /** Check a new tag, return an error text to refuse it */
  validate?: (tag: string) => string | undefined;
  placeholder?: string;
  name?: string;
  id?: string;
  disabled?: boolean;
  invalid?: boolean;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

function TagBox({ value: valueProp, defaultValue, onValueChange, suggestions, max, validate, placeholder, name, id, disabled, invalid, labels, className }: Omit<TagInputProps, keyof FieldOwnProps>) {
  const t = useTexts(TEXTS, labels);
  const [inner, setInner] = useState<string[]>(defaultValue ?? []);
  const tags = valueProp ?? inner;
  const [text, setText] = useState('');
  const [armed, setArmed] = useState(false);
  const [problem, setProblem] = useState<string | null>(null);
  const [said, setSaid] = useState('');
  const c = useControl({ id, invalid: invalid || Boolean(problem) || undefined, disabled });
  const inputRef = useRef<HTMLInputElement>(null);
  const boxRef = useRef<HTMLDivElement>(null);
  const countRef = useRef<HTMLSpanElement>(null);
  const probId = useId();
  const full = max !== undefined && tags.length >= max;
  // Every refused tag shakes the field once, also the second duplicate in a row
  const refuse = (text: string) => {
    setProblem(text);
    const el = boxRef.current;
    if (moves(el)) el.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(-6px)' }, { transform: 'translateX(5px)' }, { transform: 'translateX(-3px)' }, { transform: 'translateX(2px)' }, { transform: 'translateX(0)' }], { duration: 420, easing: 'ease-out' });
  };
  // The counter ticks with a small pop whenever the number changes
  const count = useRef(tags.length);
  useEffect(() => {
    if (count.current === tags.length) return;
    count.current = tags.length;
    const el = countRef.current;
    if (moves(el)) el.animate([{ transform: 'scale(1)' }, { transform: 'scale(1.25)', offset: 0.4 }, { transform: 'scale(1)' }], { duration: 300, easing: EASE });
  }, [tags.length]);

  const update = (next: string[]) => {
    setInner(next);
    onValueChange?.(next);
  };
  const add = (raw: string) => {
    const tag = raw.trim().replace(/,$/, '').trim();
    if (!tag) return;
    if (tags.some((x) => x.toLowerCase() === tag.toLowerCase())) return refuse(`${tag} ${t.duplicate}.`);
    if (full) return refuse(`${t.limit} (${max}).`);
    const err = validate?.(tag);
    if (err) return refuse(err);
    update([...tags, tag]);
    setText('');
    setProblem(null);
    setSaid(`${tag} ${t.added}`);
  };
  const remove = (i: number) => {
    setSaid(`${tags[i]} ${t.removed}`);
    update(tags.filter((_, j) => j !== i));
    setArmed(false);
    inputRef.current?.focus();
  };

  return (
    <div className={cn('grid w-full gap-2.5', className)}>
      <div
        ref={boxRef}
        data-invalid={c.invalid || undefined}
        data-disabled={c.disabled || undefined}
        onClick={() => inputRef.current?.focus()}
        className={cn('flex min-h-11 w-full cursor-text flex-wrap items-center gap-1.5 rounded-lg px-2 py-[5px]', controlBox)}
      >
        {tags.map((tag, i) => (
          <Chip key={tag} tag={tag} armed={armed && i === tags.length - 1} disabled={c.disabled} label={`${t.remove}: ${tag}`} onRemove={() => remove(i)} />
        ))}
        {/* Field and counter wrap together, the counter never sits alone on a line */}
        <span className="flex min-w-[8rem] flex-1 items-center">
          <input
            ref={inputRef}
            id={c.id}
            value={text}
            disabled={c.disabled}
            placeholder={full ? t.limit : tags.length ? undefined : placeholder ?? t.placeholder}
            aria-invalid={c['aria-invalid']}
            aria-describedby={cn(c['aria-describedby'], problem && probId) || undefined}
            enterKeyHint="done"
            onChange={(e) => {
              const v = e.target.value;
              setArmed(false);
              setProblem(null);
              if (v.endsWith(',')) add(v);
              else setText(v);
            }}
            onPaste={(e) => {
              const raw = e.clipboardData.getData('text');
              if (!/[,\n]/.test(raw)) return;
              e.preventDefault();
              const next = [...tags];
              for (const p of raw.split(/[,\n]/).map((x) => x.trim()).filter(Boolean)) if (!next.some((x) => x.toLowerCase() === p.toLowerCase()) && (max === undefined || next.length < max)) next.push(p);
              update(next);
            }}
            onKeyDown={(e) => {
              if (e.key === 'Enter') {
                e.preventDefault();
                add(text);
              } else if (e.key === 'Backspace' && !text && tags.length) {
                if (armed) remove(tags.length - 1);
                else setArmed(true);
              } else setArmed(false);
            }}
            onBlur={() => {
              setArmed(false);
              // Text left in the field becomes a tag instead of getting lost
              if (text.trim()) add(text);
            }}
            className="h-8 w-0 min-w-0 flex-1 bg-transparent px-1.5 text-base text-inherit outline-none placeholder:text-subtle-foreground disabled:cursor-not-allowed sm:text-sm"
          />
          {max !== undefined && (
            <span ref={countRef} aria-hidden className={cn('shrink-0 pe-1.5 text-xs tabular-nums', full ? 'font-medium text-foreground' : 'text-subtle-foreground')}>
              {tags.length}/{max}
            </span>
          )}
        </span>
      </div>
      {name && <input type="hidden" name={name} value={tags.join(',')} />}
      {problem && <Problem id={probId}>{problem}</Problem>}
      <span aria-live="polite" className="sr-only">
        {said}
      </span>
      {suggestions && suggestions.some((s) => !tags.includes(s)) && (
        <div className="flex flex-wrap items-center gap-1.5">
          <span className="me-1 text-xs text-subtle-foreground">{t.suggestions}</span>
          {suggestions
            .filter((s) => !tags.includes(s))
            .map((s) => (
              <button
                key={s}
                type="button"
                disabled={c.disabled || full}
                onClick={() => add(s)}
                className="relative inline-flex h-7 items-center gap-1 rounded-full border border-dashed border-border-strong px-2.5 text-xs font-medium text-muted-foreground transition-[background-color,color,border-color,scale] duration-200 after:absolute after:inset-x-0 after:-inset-y-2 after:content-[''] hover:border-solid hover:bg-accent hover:text-foreground active:scale-95 disabled:opacity-50"
              >
                <Icon name="plus" size={12} strokeWidth={2} />
                {s}
              </button>
            ))}
        </div>
      )}
    </div>
  );
}

/**
 * Tags in a field: Enter or comma adds, Backspace twice removes the last one, pasting a list adds all,
 * text left in the field becomes a tag on blur. With max, a counter shows how many are left.
 *   <TagInput label="Skills" defaultValue={['Branding']} suggestions={['Web design', 'SEO']} max={8} />
 */
export function TagInput({ label, hint, error, optional, labelAction, className, ...rest }: TagInputProps) {
  const wrapped = Boolean(label || hint || error);
  return withField({ label, hint, error, optional, labelAction, id: rest.id, disabled: rest.disabled, className }, <TagBox {...rest} className={wrapped ? undefined : className} />);
}

Häufige Fragen

Wie installiere ich Tag-Eingabe?

Mit aiw add @ai-website/tag-input. Der Befehl legt die Datei components/ui/tag-input.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und input. Weitere npm-Pakete braucht sie nicht.

Kann ich die Texte von Tag-Eingabe übersetzen?

Ja. Tag-Eingabe bringt 7 eigene Texte auf Deutsch und Englisch mit, etwa „Entfernen“, „hinzugefügt“ und „entfernt“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Tag-Eingabe einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Tag-Eingabe mit aiw_components und installieren sie mit aiw_add @ai-website/tag-input.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert