Skip to content
UI Kit
Accent color

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

1 variant

Variants

Tags

Suggestions as quick picks, a limit, a check per tag and an error as text. New tags pop in, removed ones shrink away, a refused one shakes the field.

Details

What Tag input brings along, read from the registry and the source.

Variants
1
Version
1.0.0 · since 1.0.0
File
components/ui/tag-input.tsx
Brings along
utilskiticoninput
npm packages
None
Languages
English and German, 7 texts of its own
Studio
Adjust in code

Props

The TagInputProps interface from components/ui/tag-input.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.

  • 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

Built-in texts

Tag input writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 7 texts
removeEnglish RemoveGerman Entfernen
addedEnglish addedGerman hinzugefügt
removedEnglish removedGerman entfernt
duplicateEnglish is already in the listGerman steht schon in der Liste
limitEnglish Limit reachedGerman Höchstzahl erreicht
suggestionsEnglish SuggestionsGerman Vorschläge
placeholderEnglish Type and press EnterGerman 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
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} />);
}

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.

Often searched

Jump to

Copied to the clipboard