Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
SelectionUpdatedFreeWCAG 2.2 AA

Multiselect

Multiselect — Dropdown / Popup / Page · 17 variants.

Preview

Live preview
@oshon-ai/components
Regions
North America
United States
Canada
Mexico
Europe
United Kingdom
Germany

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add multiselect

Wire the tokens into your Tailwind v4 stylesheet:

css
/* app/globals.css */
@import 'tailwindcss';
@import '@oshon-ai/tokens/css';
@import '@oshon-ai/tokens/tailwind';

New here? Walk through the full setup — prereqs, theming, your first render.

Usage

Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.

tsx
'use client';
import { Multiselect } from '@oshon-ai/components';

export default function Example() {
  return <Multiselect />;
}

Interactive — every Figma variant, three types

Dropdown — 288 closed field + blurred-glass popover picker
Enabled
Figma: 2862:32087
Regions
Opened (default open)
Figma: 2862:32091
Regions
North America
United States
Canada
Mexico
Europe
United Kingdom
Germany
France
Spain
Italy
Asia Pacific
Japan
Australia
Singapore
India
South Korea
Selections made
Figma: 2862:32118
Regions
Disabled
Regions
Searching (auto at ≥13 leaves)
Figma: search state
Regions
North America
United States
Canada
Mexico
Europe
United Kingdom
Germany
France
Spain
Italy
Asia Pacific
Japan
Australia
Singapore
India
South Korea
Filled chips under field
Figma: 2862:32196 (FilledChips)
Regions
United StatesUnited KingdomJapan
Required (label asterisk)
Regions
Popup — 288 standalone picker, no trigger
Enabled
Figma: 2862:32222
Filter by region
North America
United States
Canada
Mexico
Europe
United Kingdom
Germany
France
Spain
Italy
Asia Pacific
Japan
Australia
Singapore
India
South Korea
Selections made
Figma: 2862:32225
Filter by region
North America
United States
Canada
Mexico
Europe
United Kingdom
Germany
France
Spain
Italy
Asia Pacific
Japan
Australia
Singapore
India
South Korea
Page — 400 wide side-panel variant, no footer
Enabled
Figma: 2862:32307
Regions
North America
United States
Canada
Mexico
Europe
United Kingdom
Germany
France
Spain
Italy
Asia Pacific
Japan
Australia
Singapore
India
South Korea
Selections made
Figma: 2862:32308
Regions
North America
United States
Canada
Mexico
Europe
United Kingdom
Germany
France
Spain
Italy
Asia Pacific
Japan
Australia
Singapore
India
South Korea
End-to-end workflow — filter + commit on Apply
Regions
Last committed selection
— none applied yet —
Flat list (no groups) — search forced on
Tags
Design
Engineering
Operations
Sales
Go-to-market
Legal
Keyboard:
  • Space / Enter toggles the focused row (leaf or group rollup).
  • Click the chevron on a group header to collapse / expand its children.
  • SELECT ALL checks every leaf; CLEAR ALL clears the set. SELECTED (N) counter is aria-live="polite".
  • Outside-click or pressing Esc closes the dropdown popover (type="dropdown" only).
Auto-scale search: fires at ≥13 leaves per DS_SCALE_DEFAULTS.multiSelect.search. Force with searchable / disable with searchable={false}.
tsx
<InteractivePlayground />

API

Every prop is documented here directly from the component's TypeScript interface. Inherited DOM attributes (aria-*, onClick, style, etc.) work as usual but are omitted from this table.

PropTypeDefaultDescription
onChange*(next: Set<string>) => voidFires on every toggle (live selection).
options*readonly MultiSelectOption[]Options. Order preserved; groups are flagged via `hasChildren`.
selected*ReadonlySet<string> | readonly string[]Controlled selection. Pass a Set or string[].
classNamestring
defaultExpandedGroupsreadonly string[]Initial expanded groups. Default: every group open. Use `['none']` or an empty array to start collapsed.
defaultOpenbooleanUncontrolled initial open.
disabledbooleanDisabled state — closes the picker and disables the trigger.
labelstringLabel row text (dropdown). Overridden by `messages.label`.
messagesMultiSelectMessagesi18n overrides.
onApply((selected: Set<string>) => void)Fires when the Apply button is clicked (dropdown/popup). Consumers typically treat this as the commit point; `onChange` is live.
onCancel(() => void)Fires when the Cancel button is clicked. The component also calls `onChange(new Set())` so the live selection matches.
onOpenChange((open: boolean) => void)Fires on every open/close.
openbooleanControlled open (dropdown only).
placeholderstringClosed-field placeholder (dropdown).
requiredbooleanShow a red "*" after the label.
searchablebooleanForce the search field on/off. Default: auto-show at ≥13 leaves (Oshon data-scale default for multiSelect).
showFooterbooleanShow Apply/Cancel footer. Default: true (dropdown/popup), false (page).
sizeenummVisual size. Default `'m'`.
titlestringHeader title — popup + page types. Overridden by `messages.label`.
triggerVariantenumtextDropdown trigger variant. 'text' — single "Selected (N)" label (default). 'chips' — stacked chips of every selected leaf under the field.
typeenumdropdownLayout. Default `'dropdown'`.

Styling

Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.

Passing Tailwind classes

Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.

tsx
<Multiselect
  className="ring-2 ring-offset-2 ring-blue-500"
/>

Data attributes

Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
  --oshon-color-primary-700: var(--my-brand-color);
}

Interactive states

Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.

  • :hover / hover:* — pointer hover
  • :focus-visible / focus-visible:* — keyboard focus
  • :active / active:* — pressed
  • :disabled / disabled:* — set via the disabled prop

Anatomy

The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.

options

Ordered list of MultiSelectOption entries. `{ id, label }` is a leaf; `{ id, label, hasChildren: true }` is a collapsible group header; `{ id, label, parent: <groupId> }` is a nested leaf under the given group. Flat arrays (no groups) work — rows render as leaves with the group-chevron slot reserved so checkboxes stay aligned.

messages

Override any user-visible string — label, placeholder, searchPlaceholder, selectAllLabel, clearAllLabel, selectedLabel, applyLabel, cancelLabel, noMatchesLabel, selectedTrigger(count), removeChipLabel(label), landmarkLabel, toggleGroupLabel(label, expanded). Principle #11 — i18n by default.

Keyboard

Dropdown trigger is a <button> with aria-haspopup="dialog" + aria-expanded. Hierarchical multi-select uses the role="checkbox" group pattern (the listbox/option pattern forbids nested buttons, which blocks the group expand + select-all controls from coexisting): each row carries role="checkbox", aria-checked ("true" / "false" / "mixed" for partial-group rollup), and aria-label with the row label. Rows are in the tab order; Space or Enter toggles the focused row. Group headers render the expand/collapse <button> (aria-expanded + localized `toggleGroupLabel(groupLabel, expanded)`) as a sibling of the checkbox-toggle — never nested. The search <input> has aria-label = `messages.searchPlaceholder`; the "SELECTED (N)" counter is aria-live="polite" so screen readers hear the running count. Outside click and Escape collapse the popover. Focus-visible relies on native browser rings.

Accessibility

Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.

WCAG level
2.2 AA
Screen readers tested
VoiceOver (macOS), NVDA (Windows)
Last axe audit
2026-04-23

Do / Don't

✓ Do

Controlled dropdown with hierarchical options
const [selected, setSelected] = useState<Set<string>>(new Set());
return (
  <MultiSelect
    label="Regions"
    required
    options={[
      { id: 'na', label: 'North America', hasChildren: true },
      { id: 'us', label: 'United States', parent: 'na' },
      { id: 'ca', label: 'Canada', parent: 'na' },
      { id: 'eu', label: 'Europe', hasChildren: true },
      { id: 'uk', label: 'United Kingdom', parent: 'eu' },
    ]}
    selected={selected}
    onChange={setSelected}
    onApply={(next) => commitRegions(next)}
  />
);
Chips-under-field trigger variant
<MultiSelect
  triggerVariant="chips"
  options={tags}
  selected={selected}
  onChange={setSelected}
/>
Page-type panel with no footer
<MultiSelect
  type="page"
  title="Filter by region"
  options={regions}
  selected={selected}
  onChange={setSelected}
/>
Force the search field on regardless of leaf count
<MultiSelect searchable options={tags} selected={selected} onChange={setSelected} />

✗ Don't

Selecting a group by adding its id to `selected`
<MultiSelect selected={new Set(['na'])} ... />

Group rows are structural — their checked state is derived from `childrenOf(group).every(isSelected)`. Adding a group id to `selected` is a no-op for the count display and produces a confusing "half-checked" render. Mutate kids directly; the component marks the group as checked when every kid is checked.

Using MultiSelect as a single-select
<MultiSelect onChange={(next) => setValue(next.size ? Array.from(next)[0] : null)} />

The visual surface — SELECT ALL / CLEAR ALL links, the "(N) selected" counter, the Apply/Cancel footer — is wrong for single-select. Use `<Select>` (radix-backed combobox) for one-of-N cases.

Passing deep (> 2 level) hierarchies
options={[{ id: 'a', hasChildren: true }, { id: 'b', parent: 'a', hasChildren: true }, { id: 'c', parent: 'b' }]}

The Figma MultiselectItem spec (2862:32415) ships exactly two levels (L1 group + L2 leaf). Deeper hierarchies break the 32px / 56px indent contract and the checkbox rollup rule. Flatten to L1 groups + L2 leaves, or use a Tree component for arbitrary depth.

Design rationale

Single component with a `type` prop instead of three (MultiSelectDropdown / MultiSelectPopup / MultiSelectPage) because the Figma source ships them as variants of one design — they share the picker body, the select-all/clear-all row, the footer, and the MultiselectItem spec. Only the chrome around the picker differs (closed field vs title vs wider panel). Making type a prop keeps the API surface small, lets callers switch without rebinding selection state, and means an a11y fix to the listbox semantics lands once for all three. The `triggerVariant: "chips"` escape hatch stacks selected chips below the closed field (Figma FilledChips 2862:32196) without a second component. Data-scale auto-shows the search bar at ≥13 leaves per `DS_SCALE_DEFAULTS.multiSelect.search`; callers force with `searchable`. Selection is controlled (Set<string>) so hosts own the source of truth; Apply/Cancel only emit events — consumers decide whether to discard or commit the live Set.