MultiSelect

A React multi select dropdown - check off several options, the menu stays open. For filters and pickers.

MultiSelect playground

Install MultiSelect

1. New project? One command installs the package and wires everything - see Installation:

bash
pnpm dlx zyncat-ui init

2. Import the component - it loads its own stylesheet:

tsx
import { MultiSelect } from '@zyncat/ui/multi-select';

Usage

tsx
import { MultiSelect } from '@zyncat/ui/multi-select';
 
<MultiSelect ariaLabel="Channels" value={channels} onChange={setChannels} options={CHANNELS} searchable />

Compose MultiSelect in your application. No Tailwind or external styling library is required; every value resolves from Zyncat UI's token vocabulary.

MultiSelect props

MultiSelect

PropTypeDefaultDescription
options*SelectOption[] | SelectGroup[][]The choices - a flat `SelectOption[]`, or `SelectGroup[]` to render labeled sections.
valuestring[]—Controlled value - the array of selected option `value`s. Omit for uncontrolled (use `defaultValue`).
defaultValuestring[][]Initial selection when uncontrolled.
onChange(value: string[], toggled: SelectOption) => void—Fires with the NEXT array and the option that was toggled. Committing keeps the menu open.
placeholderstring'Select options'Trigger text when nothing is selected.
size'sm' | 'md' | 'lg''md'Trigger height, type and padding. The menu follows it unless `menuSize` overrides.
menuSizeMenuSizethe `size` valueMenu density on its own - row type, row padding, the filter field and the row checkbox. A row carrying a description sits one step taller than a plain one at every step.
weightMenuWeight'medium'Weight of every option label in the menu.
marker'checkbox' | 'switch''checkbox'Glyph mirroring each option's selected state at the trailing edge: a checkbox, or a switch for a settings-style menu where every option is an independent on/off. Decoration only - the option semantics are the same either way.
widthMenuWidth'trigger'Menu width. `trigger` is never narrower than the trigger, `auto` fits the options, and `sm` | `md` | `lg` are fixed steps with long labels ellipsizing.
disabledbooleanfalseDisabled - trigger is inert and the menu cannot open.
invalidbooleanfalseDanger ring + border.
loadingbooleanfalseSkeleton rows in the menu; trigger reads "Loading...".
searchablebooleanfalseType-to-filter field pinned above the list.
searchPlaceholderstring'Filter options'Placeholder for the `searchable` filter input.
highlight'neutral' | 'accent''neutral'Hue of the highlight that travels between options: the neutral wash, or the accent wash with accent ink on the active option.
railbooleanfalseShort accent bar on the leading edge of the highlight, marking the active option.
leadingIconReactNode—Your own icon node pinned before the trigger label; else the sole selected option's icon.
idstring—Base id for the trigger/menu/list ids and a11y wiring; auto-generated if omitted.
ariaLabelstring—Accessible name for the trigger and listbox - supply when there is no visible label.
...htmlAttributesHTMLAttributes<HTMLDivElement> & DataAttributes—Standard <div> attributes (className, style, data-*, ...) forwarded to the select root.
menuPropsSelectMenuHtmlProps—Standard attributes (className, style, data-*, ...) forwarded to the menu panel, which portals to <body> and inherits nothing from the root.
activateOnActivateOn'pointerdown'Whether the trigger and the options fire on `pointerdown` (snappier) or wait for `click`.
animationDisableableAnimationduration 'base' + ease 'entrance'/'exit'Menu open/close timing - motion tokens only, or `null` to disable.
triggerCustomTrigger<SelectOption[]>—Your own element in place of the built-in trigger. It is cloned with the combobox wiring - role, aria, open/close, arrow keys, and the anchor the menu measures - so it must render one focusable element that forwards its props and ref. Pass a function to read `{ open, selected }`.

SelectGroup

PropTypeDefaultDescription
labelstring—Section heading rendered above the options; omit for an unlabeled group.
options*SelectOption[]—The options in this section.

SelectOption

PropTypeDefaultDescription
value*string—The stored value - what `onChange` returns and `value` matches; must be unique.
label*ReactNode—Primary row text, and the trigger label once selected; also matched by `searchable`.
descriptionstring—Optional secondary line under the label; also matched by `searchable`.
iconReactNode—Your own icon node shown before the label.
disabledbooleanfalseNot selectable - skipped by keyboard nav and typeahead, and marked `aria-disabled`.
searchTextstring—Text used for `searchable` filtering and typeahead. Required when `label` is not a string.

Frequently asked questions

Import it per subpath and pass the options plus the selected array: import { MultiSelect } from '@zyncat/ui/multi-select', then <MultiSelect options={CHANNELS} value={channels} onChange={setChannels} ariaLabel="Channels" searchable />. options takes a flat SelectOption[] or SelectGroup[] for labelled sections, value is the array of selected option values, and onChange fires with the next array plus the option that was toggled. Drop value and pass defaultValue instead to run it uncontrolled.

No - a multiple selection dropdown that shut on every pick would make you reopen it for each choice, so toggling a row leaves the menu open and onChange fires each time with the full next array. It closes on Escape, on Tab, or on a press outside the menu, and Escape returns focus to the trigger.

The first selected option's label followed by a +N pill counting the rest, so three selections read "Design +2". One selection shows just that label, and its icon if the option carries one; none shows placeholder, which defaults to "Select options".

Both are built in. Every row draws a checkbox tick that tracks its aria-selected state, and searchable pins a type-to-filter field above the list which matches each option against its label and its description. searchPlaceholder sets that field's text; without searchable, typing into the open menu jumps to an option by prefix instead.

The native <select multiple> needs Ctrl or Cmd-click to add to a selection, renders as a scrolling box rather than a dropdown, and barely takes styling. This is a custom listbox instead: one click toggles a row, the menu stays put, and the size, highlight and rail props restyle it on top of the same design tokens as the rest of the library. It is MIT, needs no jQuery plugin and no Tailwind config, and ships zero runtime dependencies.

Yes - trigger takes your own element, or a function receiving { open, selected } where selected is the full SelectOption[] in option order, so a trigger that reads "3 channels" instead of the first +N label is one line. The element is cloned with the combobox wiring - role, aria-expanded, aria-controls, aria-activedescendant, the arrow keys and the anchor the menu measures - so it must be one focusable element that forwards its props and its ref. placeholder and leadingIcon stop applying; the menu keeps its size, its filter field and its checkbox rows, and toggling a row still leaves it open.

Yes. The trigger is a role="combobox" button wired with aria-haspopup, aria-expanded, aria-controls and aria-activedescendant, the list is a role="listbox" with aria-multiselectable, and each row is a role="option" carrying aria-selected. Arrow keys move the active option, Home and End jump to the ends, Enter toggles it - so does Space, unless searchable has put the caret in the filter field - Escape closes and returns focus to the trigger, and disabled options are skipped by keyboard nav and typeahead alike.

Set in Geist & Newsreader — animated by the house engineZyncat UI · Rev 0.11 · MIT · Built by Tabsir Ahammed · Source on GitHub