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.
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
// 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
Command Palette
Linear
Global Search with Results Grouping
Linear
Recent + Suggested Search
Raycast
Filter Builder (AND / OR)
Linear
Sidebar Navigation
Linear
Collapsible Nested Tree Nav
Notion
Tab Bar with Overflow Menu
Vercel
Breadcrumb with Dropdowns
Linear
Workspace Switcher
Slack
Deployment Status Card
Vercel
Empty State
Vercel
Error State with Retry
Notion
Slash Command Menu
Notion
Stats & Metrics Row
Vercel
Single Big Metric Card
Stripe
Usage / Quota Meter
Vercel
Skeleton Loading Grid
Linear
Activity Heatmap
Notion
Confirmation Dialog
Linear
Slide-Over Panel
Stripe
Toast / Snackbar Stack
Vercel
Issue / Task Card
Linear
Activity Feed
Linear + Slack
Comment Thread
Linear
Audit Log Timeline
Vercel
Properties Panel
Notion + Linear
Multi-Step Form Wizard
Stripe
Tag Multi-Select
Linear
Toggle Switch Group
Stripe
Inline Data Table
Notion + Linear
Pagination
Vercel
Sortable / Selectable Table
Linear
Status Badge System
Linear
Segmented Control
Notion