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
Variants
Details
Was Tag-Eingabe mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Picker und Auswahlfelder
- Bereich
- Elements
- Variants
- 1
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/tag-input.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 7 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
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.
valueoptionalstring[]defaultValueoptionalstring[]onValueChangeoptional(tags: string[]) => voidsuggestionsoptionalstring[]Quick picks under the field
maxoptionalnumbervalidateoptional(tag: string) => string | undefinedCheck a new tag, return an error text to refuse it
placeholderoptionalstringnameoptionalstringidoptionalstringdisabledoptionalbooleaninvalidoptionalboolean
Eingebaute Texte
Diese Texte schreibt Tag-Eingabe selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 7 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| remove | Englisch Remove | Deutsch Entfernen |
| added | Englisch added | Deutsch hinzugefügt |
| removed | Englisch removed | Deutsch entfernt |
| duplicate | Englisch is already in the list | Deutsch steht schon in der Liste |
| limit | Englisch Limit reached | Deutsch Höchstzahl erreicht |
| suggestions | Englisch Suggestions | Deutsch Vorschläge |
| placeholder | Englisch Type and press Enter | Deutsch 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
'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.