Tag Multi-Select

A keyboard-navigable multi-select input inspired by Linear. Selected tags render as removable chips; typing filters options; backspace removes the last tag; create new tags inline.

Forms & InputsInspired by Linear
Live PreviewInteractive
ENG-482 · Labels
featuredesign

Type a new label name to create it on the fly

Type to filter · click or ↑↓ to navigate · Backspace removes last · create new tags inline

TagMultiSelect.tsx
// Dependencies: react ^18, lucide-react
import React, { useState, useRef, useEffect, useId } from 'react';
import { X, Plus, Check } from 'lucide-react';

function cn(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}

type Tag = {
  id: string;
  label: string;
  color: string;
};

type Props = {
  options: Tag[];
  initialSelected?: Tag[];
  placeholder?: string;
  onSelectionChange?: (selected: Tag[]) => void;
};

export function TagMultiSelect({
  options,
  initialSelected = [],
  placeholder = 'Add tags…',
  onSelectionChange,
}: Props) {
  const [selected, setSelected] = useState<Tag[]>(initialSelected);
  const [query, setQuery] = useState('');
  const [open, setOpen] = useState(false);
  const [focusedIndex, setFocusedIndex] = useState(-1);
  const inputRef = useRef<HTMLInputElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const listboxId = useId();

  const filtered = options.filter(
    (o) =>
      !selected.find((s) => s.id === o.id) &&
      o.label.toLowerCase().includes(query.toLowerCase())
  );

  const canCreate =
    query.trim().length > 0 &&
    !options.find((o) => o.label.toLowerCase() === query.trim().toLowerCase()) &&
    !selected.find((s) => s.label.toLowerCase() === query.trim().toLowerCase());

  const dropdownItems: ({ type: 'option'; tag: Tag } | { type: 'create'; label: string })[] = [
    ...filtered.map((t) => ({ type: 'option' as const, tag: t })),
    ...(canCreate ? [{ type: 'create' as const, label: query.trim() }] : []),
  ];

  const addTag = (tag: Tag) => {
    const next = [...selected, tag];
    setSelected(next);
    onSelectionChange?.(next);
    setQuery('');
    setFocusedIndex(-1);
    inputRef.current?.focus();
  };

  const createTag = (label: string) => {
    const COLORS = ['#6366f1', '#f59e0b', '#10b981', '#ec4899', '#8b5cf6', '#ef4444', '#06b6d4'];
    const color = COLORS[Math.floor(Math.random() * COLORS.length)];
    addTag({ id: `tag-${Date.now()}`, label, color });
  };

  const removeTag = (id: string) => {
    const next = selected.filter((s) => s.id !== id);
    setSelected(next);
    onSelectionChange?.(next);
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Backspace' && query === '' && selected.length > 0) {
      removeTag(selected[selected.length - 1].id);
      return;
    }
    if (e.key === 'Escape') { setOpen(false); setFocusedIndex(-1); return; }
    if (!open) {
      if (e.key === 'ArrowDown' || e.key === 'Enter') { setOpen(true); setFocusedIndex(0); e.preventDefault(); }
      return;
    }
    if (e.key === 'ArrowDown') { e.preventDefault(); setFocusedIndex((i) => Math.min(i + 1, dropdownItems.length - 1)); }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setFocusedIndex((i) => Math.max(i - 1, 0)); }
    else if (e.key === 'Enter') {
      e.preventDefault();
      const item = dropdownItems[focusedIndex];
      if (item) { item.type === 'option' ? addTag(item.tag) : createTag(item.label); }
      else if (canCreate) { createTag(query.trim()); }
    }
  };

  useEffect(() => {
    const handler = (e: MouseEvent) => {
      if (containerRef.current && !containerRef.current.contains(e.target as Node)) setOpen(false);
    };
    document.addEventListener('mousedown', handler);
    return () => document.removeEventListener('mousedown', handler);
  }, []);

  useEffect(() => { setFocusedIndex(-1); }, [query]);

  return (
    <div ref={containerRef} className="relative w-full">
      <div
        onClick={() => { setOpen(true); inputRef.current?.focus(); }}
        className={cn(
          'flex flex-wrap items-center gap-1.5 min-h-[40px] px-3 py-2 rounded-lg border bg-[#0a0a0a] cursor-text transition-colors',
          open ? 'border-indigo-500/50' : 'border-[#2a2a2a] hover:border-[#3a3a3a]'
        )}
      >
        {selected.map((tag) => (
          <span
            key={tag.id}
            className="flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border"
            style={{ backgroundColor: `${tag.color}18`, borderColor: `${tag.color}30`, color: tag.color }}
          >
            {tag.label}
            <button
              onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); removeTag(tag.id); }}
              className="ml-0.5 opacity-60 hover:opacity-100 transition-opacity"
            >
              <X className="w-2.5 h-2.5" />
            </button>
          </span>
        ))}
        <input
          ref={inputRef}
          value={query}
          onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
          onFocus={() => setOpen(true)}
          onKeyDown={handleKeyDown}
          placeholder={selected.length === 0 ? placeholder : ''}
          className="flex-1 min-w-[80px] bg-transparent text-sm text-white placeholder-zinc-600 outline-none"
          role="combobox"
          aria-haspopup="listbox"
          aria-expanded={open}
          aria-controls={listboxId}
          aria-activedescendant={focusedIndex >= 0 ? `item-${focusedIndex}` : undefined}
        />
      </div>

      {open && dropdownItems.length > 0 && (
        <div
          id={listboxId}
          role="listbox"
          className="absolute left-0 right-0 top-full mt-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg shadow-2xl z-50 max-h-[180px] overflow-y-auto"
        >
          {dropdownItems.map((item, i) => {
            if (item.type === 'create') {
              return (
                <button
                  key="create"
                  id={`item-${i}`}
                  role="option"
                  aria-selected={i === focusedIndex}
                  onMouseDown={(e) => { e.preventDefault(); createTag(item.label); }}
                  onMouseEnter={() => setFocusedIndex(i)}
                  className={cn(
                    'w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors',
                    i === focusedIndex ? 'bg-white/[0.06] text-white' : 'text-zinc-400'
                  )}
                >
                  <Plus className="w-3.5 h-3.5 shrink-0 text-indigo-400" />
                  <span>Create <span className="text-white font-medium">"{item.label}"</span></span>
                </button>
              );
            }
            return (
              <button
                key={item.tag.id}
                id={`item-${i}`}
                role="option"
                aria-selected={i === focusedIndex}
                onMouseDown={(e) => { e.preventDefault(); addTag(item.tag); }}
                onMouseEnter={() => setFocusedIndex(i)}
                className={cn(
                  'w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors',
                  i === focusedIndex ? 'bg-white/[0.06]' : ''
                )}
              >
                <span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: item.tag.color }} />
                <span className="flex-1 text-left text-zinc-200">{item.tag.label}</span>
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}

// Usage:
// const OPTIONS = [
//   { id: 'bug', label: 'bug', color: '#ef4444' },
//   { id: 'feature', label: 'feature', color: '#6366f1' },
// ];
// <TagMultiSelect options={OPTIONS} placeholder="Add labels…" onSelectionChange={(tags) => console.log(tags)} />

Unlock to copy

Free access to all patterns