PixelMultiSelect

Multi-select combobox with chip-based selected values, optional search, and max-selection cap.

  • Combobox + listbox with aria-multiselectable
  • Chip rendering for selected values with keyboard removal (Backspace)
  • Optional searchable filter and clearable affordance
  • Max-selection cap with live count footer
  • Surface-aware (flat/linear) field shell with hint/error states
Status
stable
Category
forms
Since
v1.8.0

API

import { PixelMultiSelect } from '@pxlkit/ui-kit';
PropTypeDefaultDescription
valuestring[]—The selected values, in the order picked; leave unset for an uncontrolled multi-select.
defaultValuestring[]—Initial values while uncontrolled.
onChange(next: string[]) => void—Called with the selected values after every toggle, removal or clear.
options (required)PixelMultiSelectOption[]—The options of the listbox.
searchablebooleanfalseShows a search field that filters the options.
maxnumber—Most values that can be selected.
placeholderstring'Select…'Text shown while nothing is selected.
clearablebooleanfalseShows a button that clears the selection while there is one.
surface'pixel' | 'linear'—Surface override; defaults to the nearest provider.
size'sm' | 'md' | 'lg''md'Trigger height.
labelstring—Label rendered above the trigger.
hintstring—Helper text below the field; hidden while error is set.
errorstring—Error message below the field; marks the trigger invalid.
namestring—Hidden-input name. Multiple values are serialized as repeated <input type="hidden" name={name}> entries; read with FormData.getAll(name).
idstring—id of the trigger; generated when left out.

ref points to <button>.

Accessibility

WCAG target: 2.1 AA

  • combobox
  • listbox

The field holds, side by side, the chips — each a label and a native button named after it ("Remove Apple") — the combobox and, when clearable, a native "Clear selection" button: a button cannot contain a button, so none sits inside another, and each is a tab stop in reading order. The combobox (role=combobox, named by the label) has aria-controls/expanded/activedescendant, and so does the search field while focus is in it; it reads the selected labels as its value. A remove or clear button that holds focus hands it on as it goes; under the pointer they leave focus where it is. A press elsewhere on the field opens or closes the listbox and focuses the combobox; the popover anchors to the field. The field shows the combobox's keyboard focus. Listbox advertises aria-multiselectable and marks the chosen options aria-selected.

Keyboard

KeyActionWhen
TabMove through the field: each chip's remove button, the combobox, then the clear button—
ArrowDownOpen popover or move highlight down—
ArrowUpOpen popover or move highlight up—
HomeHighlight first option—
EndHighlight last option—
EnterToggle highlighted option—
SpaceToggle highlighted option from the combobox; types a space in the search field—
BackspaceRemove last selected chip when query is empty—
EscapeClose the popover; from the search field, focus returns to the comboboxpopover open
Enter / SpaceRemove the chip and focus the next chip's remove button, else the comboboxremove button focused
Enter / SpaceClear the selection and focus the comboboxclear button focused

Usage

import React from 'react';
import { PixelMultiSelect } from '@pxlkit/ui-kit';

const OPTIONS = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'solid', label: 'Solid' },
  { value: 'angular', label: 'Angular', disabled: true },
];

export function Default() {
  const [value, setValue] = React.useState<string[]>(['react']);
  return (
    <PixelMultiSelect
      label="Frameworks"
      options={OPTIONS}
      value={value}
      onChange={setValue}
      placeholder="Pick frameworks…"
    />
  );
}

Examples

Default

import React from 'react';
import { PixelMultiSelect } from '@pxlkit/ui-kit';

const OPTIONS = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'solid', label: 'Solid' },
  { value: 'angular', label: 'Angular', disabled: true },
];

export function Default() {
  const [value, setValue] = React.useState<string[]>(['react']);
  return (
    <PixelMultiSelect
      label="Frameworks"
      options={OPTIONS}
      value={value}
      onChange={setValue}
      placeholder="Pick frameworks…"
    />
  );
}

Searchable + Clearable

import React from 'react';
import { PixelMultiSelect } from '@pxlkit/ui-kit';

const OPTIONS = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'solid', label: 'Solid' },
  { value: 'angular', label: 'Angular', disabled: true },
];

export function Searchable() {
  const [value, setValue] = React.useState<string[]>([]);
  return (
    <PixelMultiSelect
      label="Frameworks"
      hint="Type to filter"
      options={OPTIONS}
      value={value}
      onChange={setValue}
      searchable
      clearable
    />
  );
}

With Max

import React from 'react';
import { PixelMultiSelect } from '@pxlkit/ui-kit';

const OPTIONS = [
  { value: 'react', label: 'React' },
  { value: 'vue', label: 'Vue' },
  { value: 'svelte', label: 'Svelte' },
  { value: 'solid', label: 'Solid' },
  { value: 'angular', label: 'Angular', disabled: true },
];

export function WithMax() {
  const [value, setValue] = React.useState<string[]>(['react', 'vue']);
  return (
    <PixelMultiSelect
      label="Pick up to 2"
      options={OPTIONS}
      value={value}
      onChange={setValue}
      max={2}
      clearable
    />
  );
}

Related