Inputs
Multi Select
A multi-selection field: removable chips with a +N collapse in the trigger, a searchable checkbox listbox, select-all / clear, group headings, and a cap that refuses out loud.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/multi-select.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "MultiSelect" component. Dependencies:
lucide-react for icons and a cn() class merger. No popover / listbox / state
library — the panel, the filter and the keyboard model are all local.
Contract
- export const MultiSelect = forwardRef<HTMLDivElement, MultiSelectProps>. The
forwarded ref lands on the relative root the panel is positioned against, and
the consumer's remaining div props spread onto that same root.
- MultiSelectOption = { value: string; label: string; description?: string;
group?: string; disabled?: boolean; disabledReason?: string }.
disabled means "locked in whatever state it is in": it can be neither added
nor removed, and disabledReason is the sentence shown to anyone who tries.
- MultiSelectProps extends
Omit<React.HTMLAttributes<HTMLDivElement>, "value" | "onChange" | "children">:
options: MultiSelectOption[]
value: string[] // controlled, in pick order
onValueChange: (value: string[]) => void
placeholder = "Select options…"
searchPlaceholder = "Search options…"
emptyText = "No options match that search."
maxSelected?: number // hard cap
maxVisibleChips = 3 // 0 = never render chips, show a count
bulkActions = true // the Select all / Clear row
disabled = false
label = "Options" // accessible name of trigger + listbox
onRefuse?: (message: string) => void // fires with the exact refused sentence
- The component owns no selection state. It owns exactly five pieces of local
state: open, query, the raw active index, the transient feedback sentence, and
the measured panel placement / max height.
Behavior
- Normalise first: selection = Array.from(new Set(value)). A duplicated value
would key two chips identically and make the counter lie, and normalising here
means every array the component emits is clean whatever it was handed.
- Filter: case-insensitive subsequence match over label only — walk the query
characters keeping a cursor into the lowercased label, and fail on the first
character not found at or after the cursor ("pd" finds "Product design").
description is deliberately not searched.
- Group, then number. Bucket the surviving options by `group` in
first-appearance order, then assign keyboard indices while walking the
buckets, so nav order is render order. Numbering the source array instead
breaks the moment two groups interleave in it (a, b, a).
- Row state: selected = the value is in the selection; blocked = option.disabled
|| (cap is full && not selected). blocked rows are dimmed but keep their
pointer events — a row that cannot be picked must still be able to say why.
- Active row: keep a raw index in state and derive
active = rows.length === 0 ? -1 : clamp(raw, 0, rows.length - 1). A list that
shrinks under a growing query can then only ever clamp back onto a real row;
typing also resets the raw index to 0.
- Keyboard, closed trigger: Enter / Space / ArrowDown / ArrowUp open the panel.
Backspace removes the last removable chip. Any other single printable
character opens the panel with that character already seeded into the query
(type-ahead), instead of being swallowed.
- Keyboard, open panel (focus lives in the search field):
ArrowDown / ArrowUp move the active row, clamped, no wrap
Home / End first / last row
Space toggle the active row, panel stays open
Enter close and return focus to the trigger (preventDefault
first, or inside a form this submits)
Escape two stage: clear a non-empty query, else close
Backspace (empty query) remove the last removable chip
printable characters filter
Space toggles and never types a space: the filter is a subsequence match, so a
space is never needed to reach a multi-word label. That frees the one key a
multi-select needs most — Enter is the "done" key, not the "pick" key,
otherwise picking six options costs six reopenings.
Bail out of both handlers when event.nativeEvent.isComposing: mid-composition
every key belongs to the IME, not to the list.
- Refusals speak. Every rejected action sets one transient sentence rendered
above the footer (text-destructive for refusals, muted for informational
results) and mirrored into a polite live region, calls onRefuse, and clears
itself after 4s — clearing is what lets the next identical refusal be
announced again. The three refusals:
locked row -> disabledReason, or `"<label>" is locked and cannot be changed.`
cap full, adding -> `You can pick <max> at most. Remove one before adding "<label>".`
select-all overflow -> `Selecting <n> more would reach <total> of a maximum <max> — <left> slots left. Narrow the search first.`
Removing is never refused by the cap; the cap only guards additions.
- Bulk actions operate on what is on screen: Select all adds every visible row
that is neither selected nor locked, and refuses as a whole when
selection.length + additions > maxSelected rather than filling to the brim.
Clear removes only unlocked values, so a locked selection survives it. Both
buttons go aria-disabled when there is nothing to do and still answer with a
sentence when pressed.
- Chips: visible = chips.slice(0, maxVisibleChips), overflow = chips.length -
visible.length, and a "+N more" pill renders when overflow > 0. Set
maxVisibleChips to 0 to drop chips entirely for a "N selected" summary. A
value with no matching option still gets a chip labelled with the raw value
(dashed border) — dropping it would make the trigger lie about the selection.
Locked chips show a padlock instead of a remove button.
- Focus choreography: the remove button being pressed is also the node about to
unmount, so record the chip index in a ref, and after the commit re-query the
surviving remove buttons and focus the one at min(index, count - 1), or the
trigger when none are left. Without it every removal drops focus on the
document body. Clicking a row refocuses the search field so typing continues.
- Panel placement: on open (and on window resize) measure the trigger rect
against the nearest ancestor whose computed overflow is not "visible" — cards,
sidebars and dialogs clip, and a panel measured against the viewport renders
straight into that clip. spaceBelow = clip.bottom - rect.bottom - 8,
spaceAbove = rect.top - clip.top - 8; flip above when the rendered panel is
taller than spaceBelow and spaceAbove is larger, then cap max-height to
max(180, chosen space) so the list scrolls instead of being cut off.
- Closing: pointerdown outside the root, Escape, Enter, or focus leaving the
root with a real relatedTarget (Tab out). Going disabled also closes.
- ARIA: the trigger is a div with role="combobox" + aria-haspopup="listbox" +
aria-expanded + aria-controls, never a <button> — the chips carry real button
remove controls and interactive content inside a button is invalid HTML. The
list is role="listbox" aria-multiselectable="true"; rows are role="option"
with aria-selected and aria-disabled; groups are a role="group" li with
aria-label plus an aria-hidden visual heading and a role="presentation" inner
list. The search field carries aria-autocomplete="list", aria-controls and
aria-activedescendant pointing at the active row id — focus never leaves it.
The checkbox square is a drawing with aria-hidden: role="checkbox" is not a
valid child of a listbox, so aria-selected carries the truth.
- disabled uses aria-disabled plus guards in every handler, never the native
attribute, and the trigger keeps tabIndex 0: the browser blurs a natively
disabled node to the document body, so a field disabling under the cursor
would take the keyboard user's place in the page with it.
- Degenerate cases: empty options or a query matching nothing renders one
emptyText row and leaves the active index at -1; every option locked makes
Backspace answer "nothing to remove"; maxSelected smaller than the incoming
selection simply blocks additions, it never truncates the consumer's value;
duplicate option values render twice and toggle together — dedupe upstream.
- Cleanup: the outside-pointerdown listener only exists while open; the resize
listener and the placement rAF are removed and cancelled together; the
feedback timer is cleared before every replacement and on unmount.
Rendering & styling
- Semantic tokens only. Trigger: border-input, bg-transparent, shadow-xs, and
border-ring + ring-3 ring-ring/50 while open. Chips: bg-secondary /
text-secondary-foreground. Overflow pill: bg-muted / text-muted-foreground.
Panel: bg-popover / text-popover-foreground, border-border, shadow-md, z-50.
Active row: bg-accent / text-accent-foreground. Checked box: bg-primary +
border-primary + text-primary-foreground. Refusal: text-destructive. Focus
rings: focus-visible:ring-2 ring-ring everywhere. Merge every className
through cn().
- Motion is decorative only: the panel fades and slides one step under
motion-safe, colour and transform transitions carry motion-reduce variants,
and the chevron rotates 180deg when open. With motion off the component is
identical in function.
- Labels truncate (max-w on chips, truncate on rows) so a long option name never
widens the field or the panel.
Customization levers
- Density: the row is px-2 py-1.5 with a two-line label + description; drop the
description and the list halves in height. min-h-9 on the trigger is what lets
chips wrap onto a second line instead of scrolling sideways.
- Chip budget: maxVisibleChips is the whole layout dial — 3 for a form field, 1
in a toolbar, 0 for a "N selected" filter-bar pill.
- Cap policy: swap the whole-or-nothing Select all for a fill-to-the-cap variant
by slicing additions to maxSelected - selection.length; keep the sentence
either way, because a silent partial fill is the confusing version.
- Filter: widen matchesQuery to search description or a keywords[] field, or
swap the subsequence for a plain includes() if fuzzy hits feel loose — but
then re-enable Space as a typed character.
- Semantics: this control is deliberately a listbox. If your rows need to be
individually focusable (nested trees, per-row menus), move to role="group"
with real checkboxes and drop aria-activedescendant.
- Tokens: bg-secondary chips read as neutral; bg-primary/10 + text-primary reads
as branded. The checked box is the only place bg-primary appears, so it stays
the accent even after a theme swap.
- Slots worth keeping or cutting: group headings (drop by omitting group), the
bulk row (bulkActions), the footer counter (only meaningful with maxSelected),
the padlock affordance (only meaningful with disabled options).Concepts
- Space toggles, Enter closes — the panel surviving a pick is what makes bulk selection possible; Enter is the "done" key, and because the filter is a subsequence match a space is never needed to reach a multi-word label, so it can be spent on toggling instead.
- Refusal that explains itself — hitting the cap, pressing a locked row or asking for a Select all that would overflow all produce one sentence in the panel, in a polite live region and through
onRefuse. Blocked rows keep their pointer events on purpose: silently ignoring the click is the failure mode being designed out. - Chip collapse, not chip hiding — the trigger shows the first N chips and folds the rest into "+N more" (or a bare count at
maxVisibleChips=0), so a growing selection changes the label, never the height of the form. - Nav order is render order — indices are assigned after grouping, so arrow keys walk the rows as they are painted even when two groups interleave in the source array, and
aria-activedescendantkeeps focus in the search field the whole time. - Focus survives its own removal — the remove button that was pressed is the node that unmounts, so the component re-queries the surviving buttons after the commit and hands focus to the one that took the slot, or back to the trigger.
- Locked is a state, not a colour — a locked option cannot be added or removed, keeps a padlock instead of a ×, survives Clear, and is skipped by Backspace; the workspace owner stays in the list no matter how the field is driven.
Icon Picker
A searchable icon grid in a popover — the pickable set is a name → component map you supply, paged into bounded windows, with combobox keyboard navigation and recents.
Checkbox Group
A flat fieldset of checkboxes with a genuinely indeterminate select-all, per-row descriptions, shift-range selection, and min/max limits that answer with a message instead of a dead control.