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…

"use client"

import * as React from "react"
import { Check, ChevronDown, Lock, Search, X } from "lucide-react"
import { cn } from "@/lib/utils"

/** Gap kept between the trigger and whatever edge the panel is measured against. */
const VIEWPORT_GAP = 8
/** The panel never shrinks below this, even in a cramped container — it scrolls instead. */
const PANEL_MIN_HEIGHT = 180
/** How long a refusal / bulk-action sentence stays on screen and in the live region. */
const FEEDBACK_MS = 4000

/**

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/multi-select.json

Prompt

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-activedescendant keeps 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.

On This Page