MultiSelector
A checkbox dropdown for selecting multiple values from a list. Selected items can display as a count, labels, or badges. Use it for filtering or when presenting a finite set of options where multiple choices are needed.
Installation
npm install @astryxdesign/core@0.7.1 @astryxdesign/theme-neutral@0.7.1 @stylexjs/stylex@0.19.0
Requires React 19+, base CSS and a Theme provider. See Setup.
Multi Selector
A checkbox dropdown for selecting multiple values from a list. Selected items can display as a count, labels, or badges. Use it for filtering or when presenting a finite set of options where multiple choices are needed.
Usage guidance
- Use for a moderate, finite set of options where multiple choices are needed.
- Enable search filtering when the list exceeds ~15 options.
- Use renderOption for custom option rows; the checkbox affordance remains owned by MultiSelector.
- Enable select-all when most users will want all or nearly all options selected.
- Use inside InputGroup only when the control needs a short prefix or suffix addon as part of one decorated input surface; prefer count or labels trigger display so the group stays single-line.
- Use variant="secondary" when a multi-selector sits beside secondary buttons or segmented controls, and variant="ghost" when it sits beside ghost buttons. If validation status is needed there, prefer statusVariant="tooltip" so the toolbar height stays compact.
- Use presentation="adaptive" when the multi-selector should become a bottom sheet on compact touch screens.
- Avoid: Use for single-value selection; use Selector instead.
- Avoid: Show more than ~20 options without enabling search.
- Avoid: Wrap a disabled MultiSelector in Tooltip to explain why it is disabled; disabled triggers swallow the hover events the wrapper needs. Use the disabledMessage prop instead.
Theming API
{
"targets": [
{
"className": "astryx-multi-selector",
"visualProps": [
"variant",
"size",
"status"
],
"states": [
"disabled",
"readonly"
]
},
{
"className": "astryx-multi-selector-empty-state"
},
{
"className": "astryx-multi-selector-search"
},
{
"className": "astryx-multi-selector-section-heading"
},
{
"className": "astryx-multi-selector-indicator-icon",
"states": [
"state"
]
},
{
"className": "astryx-multi-selector-option",
"visualProps": [
"size"
],
"states": [
"select-all",
"selected",
"disabled"
]
},
{
"className": "astryx-multi-selector-popup"
}
]
}
Multi Selector
Multi-select dropdown with checkboxes for choosing multiple items.
| Prop | Type | Default | Description |
|---|---|---|---|
| label * | string | Label text for accessibility. | |
| options * | MultiSelectorOptionType[] | Array of items: strings, objects with value/label/icon/disabled, dividers, or sections. An option may carry action: one node — an IconButton, a Button, a menu trigger — the caller renders and names. Once any option does, the popup is a grid whose rows pair the option with its action: Up/Down move rows, the inline-end arrow reaches the action, Enter fires it, pointer and touch press it directly, and pressing it never changes the selection. | |
| value * | string[] | Currently selected values. | |
| onChange * | (value: string[], change?: MultiSelectorChange) => void | Called when the selection changes. A creation (hasCreate) arrives with {type: "create", query} as the second argument and the query appended to value; the caller adds the matching option in that same update. Every other change passes no descriptor, so a one-argument handler keeps working. | |
| changeAction | (value: string[]) => void | Promise<void> | Async action on change. Fires after onChange. | |
| placeholder | string | 'Select...' | Placeholder text shown when no value is selected. |
| size | 'sm' | 'md' | 'lg' | 'md' | Size variant for the selector. |
| variant | 'input' | 'secondary' | 'ghost' | 'input' | Visual trigger style. input is the bordered input treatment for forms; secondary matches the secondary buttons of the active theme (borderless and neutral-filled by default) for navs and toolbars; ghost is borderless and transparent, matching ghost buttons for toolbar usage. |
| triggerDisplay | 'count' | 'labels' | 'badges' | 'count' | How to display selected items in the trigger. |
| formatValue | (items: {value: string; label: string}[]) => string | Formats the trigger text when triggerDisplay="count" or "labels". Receives the selected items (value plus resolved label); the count is items.length. Not used by triggerDisplay="badges". | |
| maxBadges | number | 3 | Maximum badges to show before "+N". Only for triggerDisplay="badges". |
| hasSelectAll | boolean | Whether to show a select-all checkbox. | |
| selectAllLabel | string | 'Select all' | Label for the select-all checkbox. |
| hasSearch | boolean | Whether to show a search input for filtering options. As the user types, the match count (or "No results found") is announced to screen readers via a polite live region. The search field has built-in affordances: a leading magnifier icon and, once a query is typed, a trailing clear (✕) button that resets the query and returns focus to the input. | |
| searchPlaceholder | string | 'Search...' | Placeholder text for the search input. |
| emptyText | ReactNode | 'No options' | Content shown in the dropdown panel when there are no options to show, and announced in a polite live region when the panel opens. The announcement is the text this content renders, read from the DOM, so an element is announced as written and aria-hidden parts are left out of both. Not shown while isLoading. |
| emptySearchText | ReactNode | 'No results found' | Content shown in the dropdown panel when a search query matches no options, and announced in a polite live region at the same time. The announcement is the text this content renders, read from the DOM, so an element is announced as written and aria-hidden parts are left out of both. |
| hasCreate | boolean | false | With hasSearch, offers a Create "<query>" row first in the list when the typed text equals no option label under the search’s case-insensitive matching. Picking it, or Enter with nothing highlighted, calls onChange with the query appended to value and a {type: "create", query} descriptor, then clears the search; the caller must add an option for the new value in that update. Nothing is offered while isLoading. Without hasSearch it warns in development and offers nothing. |
| isDisabled | boolean | Disables the selector. | |
| isReadOnly | boolean | false | Makes the selector read-only: the selected values stay visible, focusable, and included in form submission, and retain their combobox identity with aria-readonly. The selection surface, clear action, and disclosure indicator are removed. Unlike isDisabled, the control is not dimmed. isDisabled takes precedence when both are set. |
| htmlName | string | The HTML name attribute for form submissions. Renders one hidden input per selected value, like a native multi-select. | |
| disabledMessage | string | Explains why the selector is disabled. With isDisabled, shows a tooltip on hover/keyboard focus and keeps the trigger focusable via aria-disabled (activation stays blocked). Use this instead of wrapping a disabled MultiSelector in Tooltip. Disabled controls swallow the hover events an external Tooltip needs. | |
| isLabelHidden | boolean | Visually hides the label while keeping it accessible. | |
| description | string | Helper text displayed below the label. | |
| isOptional | boolean | Marks the field as optional. | |
| isRequired | boolean | Marks the field as required. | |
| isLoading | boolean | Shows a loading spinner in the trigger. | |
| status | {type: 'error' | 'warning' | 'success', message?: string} | Validation status with an optional message. | |
| statusVariant | 'attached' | 'detached' | 'tooltip' | 'attached' for input selectors; 'detached' for secondary and ghost selectors | How the status message is placed relative to the input. attached overlaps directly below the bordered input and is only valid for the input variant; secondary and ghost selectors detach attached status messages by default. Use tooltip for compact toolbar controls. |
| renderOption | (option: MultiSelectorOptionData) => ReactNode | Custom render function for each selectable option in the dropdown. Not called for dividers, sections, or the select-all row. | |
| indicatorPosition | 'start' | 'end' | 'start' | Which edge of the option row carries the checkbox. end pushes it to the far edge of the row, including on the select-all row. |
| presentation | 'popover' | 'bottom-sheet' | 'adaptive' | 'popover' | How the option list is presented. adaptive uses a bottom sheet on compact touch screens and an anchored popover otherwise. |
| width | SizeValue | Width of the field (number = pixels, string used as-is, e.g. "100%"). Sizes the whole field (label, control, and status) so they stay aligned. | |
| startIcon | IconType | ReactNode | Icon displayed at the start of the selector trigger. | |
| hasClear | boolean | false | Shows a clear button when values are selected. |
| isDefaultOpen | boolean | false | Whether the dropdown starts open on mount. |
| renderTrigger | (props: MultiSelectorRenderTriggerProps) => ReactNode | Render the control the panel hangs off — a glyph in a list row, a chip, an icon button — instead of the selector's own field and button. Spread the given props ({ref, id, onClick, onKeyDown, onFocus, aria-haspopup, aria-expanded, aria-controls, aria-busy}) onto it; the listbox is anchored to that control and named by label. The field chrome (Field, status, clear, spinner) is not rendered. Pair with handleRef to open from a keystroke elsewhere. | |
| handleRef | React.Ref<MultiSelectorHandle> | Imperative handle with open(), close(), toggle() and isOpen(). Prefer it over mirroring open state in the parent; the selector owns its visibility. | |
| onOpenChange | (isOpen: boolean) => void | Called whenever the panel opens or closes, however it happened: the trigger, the keyboard, a light dismiss, Escape, or the handle. | |
| onClick | (event: MouseEvent) => void | Click handler on the trigger (or the renderTrigger control). Runs after the built-in toggle and cannot cancel it. | |
| onFocus | (event: FocusEvent) => void | Focus handler on the trigger. Runs before the selector's own focus handling. | |
| onKeyDown | (event: KeyboardEvent) => void | Key handler on the trigger button. Runs after the built-in keyboard model and is skipped for keys it handles and cancels. | |
| containerXstyle | StyleXStyles | StyleX styles for the field container (label, control, and status together). No effect inside InputGroup (style the InputGroup instead) or in a horizontal-labels FormLayout. Root xstyle, className, and style are not accepted. | |
| containerClassName | string | Class name for the field container. Same targeting as containerXstyle. | |
| containerStyle | CSSProperties | Inline styles for the field container. Same targeting as containerXstyle. | |
| controlXstyle | StyleXStyles | StyleX styles for the visual control box: the element that paints the border and background and holds the value, icons, and end controls. Never the trigger button itself. | |
| controlClassName | string | Class name for the visual control box. | |
| controlStyle | CSSProperties | Inline styles for the visual control box. |
Anatomy
- Field: Standalone Field shell that provides the label and optional supporting content; omitted inside InputGroup.
- Trigger: Painted control that displays the current selection or placeholder and opens the selection surface when editable.
- Icon-rendered start icon: Optional leading semantic icon or icon component rendered through Icon.
- Caller-rendered start content: Optional arbitrary React content rendered directly at the start of the trigger.
- Trigger clear button: Shared clear action that removes every selected value when hasClear is enabled.
- Status icon: Status glyph shown in place of the disclosure indicator for attached or tooltip status.
- Indicator icon: Trailing chevron shown when status presentation does not replace it; reflects collapsed or expanded state.
- Search row: Panel header with a borderless search input and optional clear action.
- Search icon: Leading magnifier rendered through Icon inside the search row.
- Search clear button: Shared clear action shown in the search row while a query is present.
- Option row: Selectable row for an option or the optional select-all choice.
- Option checkbox indicator: CheckboxInput indicator that presents each row’s selected, unselected, or indeterminate state.
- Option divider: Divider supplied in the public options data to separate adjacent option groups.
- Section heading: Visible heading for a labeled group of option rows.
- Empty state: Message shown when the shared panel content has no options or no search matches.
- Pointer popup: Anchored painted surface that hosts the shared panel content for popover presentation.
- Touch sheet heading: Heading above the shared panel content in bottom-sheet presentation.
- Touch sheet: BottomSheet surface that hosts the same panel content for bottom-sheet presentation.
Examples
- Multi Selector:
- MultiSelector — Bottom Sheet: Keeps a multi-selection list open in a bottom sheet while choices are toggled.
- MultiSelector — Column Visibility: Column visibility toggle with hidden label, search, select-all, and selection count.
- MultiSelector — Form Composition: Two multi-selectors in a form with required/optional states.
- MultiSelector — Ghost Toolbar: Borderless MultiSelector variant composed with ghost buttons in a toolbar.
- MultiSelector — Searchable: Multi-select with search filtering and select-all.
- MultiSelector — Sectioned Permissions: Multi-select with options grouped into labeled sections.