Tag input
Several values in one field: Enter or comma adds, Backspace twice removes, pasted lists split on their own.
One of 7 components in Pickers and selections
Variants
Details
What Tag input brings along, read from the registry and the source.
- Category
- Pickers and selections
- Area
- Elements
- Variants
- 1
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/tag-input.tsx
- npm packages
- None
- Languages
- English and German, 7 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The TagInputProps interface from components/ui/tag-input.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.
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
Built-in texts
Tag input writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 7 texts
| Key | English | German |
|---|---|---|
| remove | English Remove | German Entfernen |
| added | English added | German hinzugefügt |
| removed | English removed | German entfernt |
| duplicate | English is already in the list | German steht schon in der Liste |
| limit | English Limit reached | German Höchstzahl erreicht |
| suggestions | English Suggestions | German Vorschläge |
| placeholder | English Type and press Enter | German Tippen und Enter drücken |
Accessibility
Read from the source of the component.
- Tag input handles the keys Enter, Backspace and Space itself.
- Sets aria-describedby, aria-invalid, 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.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still.
- 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, 236 lines
'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} />);
}Frequent questions
How do I install Tag input?
With aiw add @ai-website/tag-input. The command puts components/ui/tag-input.tsx into your project, the code is yours from then on. It brings along icon and input. It needs no further npm packages.
Can I translate the texts of Tag input?
Yes. Tag input carries 7 texts of its own in English and German, such as “Remove”, “added” and “removed”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Tag input?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Tag input with aiw_components and install it with aiw_add @ai-website/tag-input.