# MultiSelect (/docs/multi-select)





`MultiSelect` lets the user pick several values, rendered as removable [`Pill`](/docs/pill)s in a wrapping [`PillsInput`](/docs/pills-input)-style field, searchable by default. It's the whole family in one component: the [`Combobox`](/docs/combobox) engine (in `multiple` mode) drives selection, the control is the pills shell, and each value is a `Pill`.

```tsx
import { MultiSelect } from '@42-central/ui-react/multi-select';

<MultiSelect
  data={['Libft', 'ft_printf', 'Get Next Line', 'Minishell']}
  defaultValue={['Libft']}
  placeholder="Pick projects"
/>
```

## Playground [#playground]

<story.Playground />

## Adding & removing [#adding--removing]

Type to filter, pick to add — a check marks selected options. Remove a value with its pill's &#x2A;*×**, **Backspace** on the empty field, or the **clear** button (`clearable`). `data` accepts the same shapes as [`Select`](/docs/select), including groups.

## Hide picked options [#hide-picked-options]

With `hidePickedOptions`, chosen values drop out of the dropdown so the list only ever shows what's still addable.

<story.Preview variant="Hide picked" />

## Max values [#max-values]

`maxValues` caps the selection — once reached, adding more is rejected (removals still work).

<story.Preview variant="Max values" />

## Not searchable [#not-searchable]

`searchable={false}` turns it into a click-to-open multi-picker — the field no longer filters, it just toggles the dropdown.

<story.Preview variant="Not searchable" />

## Max lines [#max-lines]

`maxLines&#x60; caps the pills at N rows; any beyond collapse into a static **+N** counter (it's measured, so it adapts to the field's width). The hidden values stay selected and submitted — deselect them from the dropdown.

<story.Preview variant="Max lines" />

## Start & end slots [#start--end-slots]

`startSlot` and `endSlot` place content at the field's inline-start / inline-end with the same slot layout as [`Input`](/docs/input). `endSlot` renders before the built-in clear button and chevron.

<Preview align="block" code="&#x22;import { Search } from 'lucide-react';\n\n<MultiSelect\n  data={['Libft', 'ft_printf', 'Get Next Line', 'Minishell']}\n  defaultValue={['Libft']}\n  startSlot={<Search />}\n  placeholder=\&#x22;Pick projects\&#x22;\n/>&#x22;">
  <div className="w-full max-w-[22rem]">
    <MultiSelect data="['Libft', 'ft_printf', 'Get Next Line', 'Minishell']" defaultValue="['Libft']" startSlot="<Search />" placeholder="Pick projects" />
  </div>
</Preview>

## Colored options [#colored-options]

Give a `data` item a `color` and its pill picks up that palette once selected — the same [`Color`](/docs/pill) accent `Pill` and `Badge` already use.

```tsx
const statuses = [
  { value: 'todo', label: 'To do', color: 'neutral' },
  { value: 'in-progress', label: 'In progress', color: 'blue' },
  { value: 'done', label: 'Done', color: 'green' },
];

<MultiSelect data={statuses} defaultValue={['in-progress']} />
```

<story.Preview variant="Colored options" />

## Custom rendering [#custom-rendering]

`renderItem` owns each dropdown option's content — the same `(item, state) => ReactNode` renderer as [`Select`](/docs/select#custom-rendering), with `item` being `{ value, label, disabled }` and state `{ selected, highlighted }`. The pills in the field still render from the label; only the dropdown rows change. Define the function in a `"use client"` module so it survives the server → client boundary.

<story.Preview variant="Custom rendering" />

### Typed payload [#typed-payload]

Attach arbitrary data to an item via `payload` and read it back as `item.payload` in `renderItem` — inferred from `data`, and `undefined` for bare-string or payload-less items. Only the dropdown rows see it; the pills still render the label.

```tsx
'use client';
import { Badge } from '@42-central/ui-react/badge';

const projects = [
  { value: 'libft', label: 'Libft', payload: { xp: 420 } },
  { value: 'webserv', label: 'Webserv', payload: { xp: 1340 } },
];

// item.payload is inferred as { xp: number } | undefined
const renderOption = (item) => (
  <span className="flex flex-1 items-center justify-between gap-2">
    {item.label}
    {item.payload && <Badge>{item.payload.xp} XP</Badge>}
  </span>
);

<MultiSelect data={projects} renderItem={renderOption} />
```

## Controlled [#controlled]

`value` is a `string[]`; `onChange` hands you the full selection on every add or remove.

```tsx
'use client';
import { useState } from 'react';
import { MultiSelect } from '@42-central/ui-react/multi-select';

export function ProjectPicker() {
  const [value, setValue] = useState<string[]>([]);
  return (
    <MultiSelect
      data={['Libft', 'ft_printf', 'Get Next Line', 'Minishell']}
      value={value}
      onChange={setValue}
      clearable
    />
  );
}
```

## Label, description & error [#label-description--error]

Built-in via [`Field`](/docs/field), like the rest of the family:

<Preview align="block" code="&#x22;<MultiSelect\n  label=\&#x22;Projects\&#x22;\n  description=\&#x22;Pick all that apply\&#x22;\n  data={['Libft', 'ft_printf', 'Get Next Line']}\n  defaultValue={['Libft']}\n/>&#x22;">
  <div className="w-full max-w-[22rem]">
    <MultiSelect label="Projects" description="Pick all that apply" data="['Libft', 'ft_printf', 'Get Next Line', 'Minishell', 'ft_transcendence']" defaultValue="['Libft']" placeholder="Pick projects" />
  </div>
</Preview>

## API [#api]

<TypeTable
  type="{
  data: { type: 'string[] | SelectItem[] | SelectGroup[]', description: 'Options. Same shapes as Select; an item\'s optional color tints its selected pill.' },
  value: { type: 'string[]', description: 'Controlled selection.' },
  defaultValue: { type: 'string[]', description: 'Uncontrolled initial selection.' },
  onChange: { type: '(value: string[]) => void', description: 'Fires with the full selection.' },
  renderItem: {
    type: '(item, state) => ReactNode',
    description: 'Render each dropdown option yourself. item is { value, label, disabled }; state is { selected, highlighted }. Define it in a &#x22;use client&#x22; module.',
  },
  placeholder: { type: 'string', description: 'Placeholder shown while nothing is selected.' },
  searchable: { type: 'boolean', default: 'true', description: 'Type to filter the options.' },
  searchPlaceholder: { type: 'string', description: 'Placeholder for the search field once a value is already picked (before that, the field shows `placeholder` instead).' },
  clearable: { type: 'boolean', default: 'false', description: 'Show a clear-all button.' },
  maxValues: { type: 'number', description: 'Cap the number of selected values.' },
  maxLines: { type: 'number', description: 'Cap the pills at N rows; the rest collapse into a static &#x22;+N&#x22; counter.' },
  startSlot: { type: 'ReactNode', description: 'Inline-start adornment (icon, label).' },
  endSlot: { type: 'ReactNode', description: 'Inline-end adornment — before the clear button / chevron.' },
  hidePickedOptions: { type: 'boolean', default: 'false', description: 'Hide already-selected options.' },
  empty: { type: 'ReactNode', default: '&#x22;Nothing found&#x22;', description: 'Message when no option matches. Pass null to render nothing.' },
  size: { type: '&#x22;xs&#x22; | &#x22;sm&#x22; | &#x22;md&#x22; | &#x22;lg&#x22; | &#x22;xl&#x22;', default: '&#x22;md&#x22;', description: 'Control / pill / option sizing.' },
  variant: { type: '&#x22;default&#x22; | &#x22;unstyled&#x22;', default: '&#x22;default&#x22;', description: 'Control shell treatment.' },
  color: { type: 'Color', default: '&#x22;brand&#x22;', description: 'Focus-ring accent.' },
  label: { type: 'ReactNode', description: 'Field label.' },
  description: { type: 'ReactNode', description: 'Field helper text.' },
  error: { type: 'ReactNode', description: 'Field error; sets the invalid state.' },
  required: { type: 'boolean', default: 'false', description: 'Required flag + asterisk.' },
  disabled: { type: 'boolean', default: 'false', description: 'Disable the control.' },
  readOnly: { type: 'boolean', default: 'false', description: 'Read-only (no add / remove).' },
  name: { type: 'string', description: 'Name for the underlying input.' },
  translations: {
    type: '{ triggerLabel?: string; clearTriggerLabel?: string; removeValue?: (label: string) => string }',
    description: &#x22;Accessible names of the chevron (default \&#x22;Show options\&#x22;), the clear-all button (default \&#x22;Clear value\&#x22;) and each value pill's remove button (removeValue, given the value's label; default \&#x22;Remove\&#x22;) — triggerLabel and clearTriggerLabel are the same keys across the combobox family (Select, Autocomplete, MultiSelect, ComboboxList, MultiComboboxList), so one translation object covers them all. A partial override keeps the other defaults.&#x22;,
  },
  classNames: { type: '{ field?, control?, input?, option?, popover?, pill? }', description: 'Per-part class overrides.' },
  testId: { type: 'string', description: 'data-testid on the real search input. Each data item also takes its own optional testId, defaulting to `item-${value}` — see the Testing guide.' },
}"
/>

## Accessibility [#accessibility]

* Full combobox keyboard model from Ark: type to filter, `↑` / `↓` to move, `Enter` to toggle an option, `Backspace` on the empty field to remove the last value, `Esc` to close.
* Each pill's × is an independently focusable, labelled button; `role="combobox"` / `listbox` / `option` and the active-descendant wiring come from the Ark machine, with label / description / error associated via the built-in `Field`.
* RTL: the pills, field, and dropdown all mirror under `dir="rtl"`.
