# MultiSelect

> Package `@umriss-ui/core`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/core/multiselect/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/core/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/core/llms-full.txt).

Several values from a list (multi-select, tag picker): a project team, services to filter by. The chosen ones stand as chips in the field, with a view in the panel that shows only them.

```ts
import { MultiSelect } from "@umriss-ui/core";
```

Demo page: https://romanhaendler.github.io/umriss-ui/core/multiselect/

The field stays one line tall: as many chips as fit, the rest as a “+N” button that opens the panel on the chosen values.

Choosing never moves it: it fills its place, or in a row stands at twenty characters, or at its `chars`, and the chips follow its width ([Sizes](https://romanhaendler.github.io/umriss-ui/core/sizes/)).

The panel has a search, select all, select none and invert for what the search found, and it never reorders the list.

## Examples

### Chips in the field

The chosen values stand as chips; what does not fit becomes a “+N” button that opens the chosen view.

```tsx
import { useState } from "react";
import { FormField, MultiSelect, Stack, Text } from "@umriss-ui/core";

const PEOPLE = [
  { value: "maya", label: "Maya Lindgren" },
  { value: "arjun", label: "Arjun Mehta" },
  { value: "chloe", label: "Chloe Durand" },
  { value: "noah", label: "Noah Fischer" },
  { value: "eva", label: "Eva Novak" },
  { value: "luis", label: "Luis Moreno" },
  { value: "hana", label: "Hana Sato" },
  { value: "kofi", label: "Kofi Mensah" },
];

export default function ChipsInTheField() {
  const [team, setTeam] = useState<string[]>(["arjun", "noah", "eva", "hana"]);

  return (
    <Stack gap={3} style={{ maxWidth: 360 }}>
      <FormField label="Project team">
        <MultiSelect value={team} onChange={setTeam} placeholder="Choose people" options={PEOPLE} />
      </FormField>
      <Text size="xs" tone="muted" mono>
        value: [{team.join(", ")}]
      </Text>
    </Stack>
  );
}
```

### States

Set `emptyText` for a search that finds nothing; errors come from the `FormField`; `disabled` locks the field but keeps what is chosen visible, even on a `disabled` row.

```tsx
import { FormField, Grid, MultiSelect, type MultiSelectOption } from "@umriss-ui/core";

const SKILLS: MultiSelectOption[] = [
  { value: "react", label: "React" },
  { value: "ios", label: "iOS" },
  { value: "android", label: "Android" },
  { value: "design", label: "Interaction design" },
  { value: "a11y", label: "Accessibility audits" },
  { value: "qa", label: "Test automation", disabled: true },
];

export default function States() {
  return (
    <Grid minItemWidth="240px" gap={4}>
      <FormField label="Skills" hint="Search for “zz” to see the empty result.">
        <MultiSelect value={[]} onChange={() => {}} options={SKILLS} placeholder="Choose skills" emptyText="No skill matches. Ask the team lead to add it." />
      </FormField>
      <FormField label="Skills" error="Choose at least one skill for the sprint.">
        <MultiSelect value={[]} onChange={() => {}} options={SKILLS} placeholder="Choose skills" />
      </FormField>
      <FormField label="Skills" hint="Set by the team lead.">
        <MultiSelect value={["react", "qa"]} onChange={() => {}} options={SKILLS} disabled />
      </FormField>
      <FormField label="Skills" hint="More than fits: the rest as +N.">
        <MultiSelect value={["react", "ios", "android", "design", "a11y"]} onChange={() => {}} options={SKILLS} />
      </FormField>
    </Grid>
  );
}
```

### With an error

A `FormField` with an `error` marks the field invalid by itself. Without one, set `invalid` and tie your message to the field with `aria-describedby`.

```tsx
import { useId, useState } from "react";
import { MultiSelect, Stack, Text } from "@umriss-ui/core";

const SKILLS = [
  { value: "react", label: "React" },
  { value: "ios", label: "iOS" },
  { value: "android", label: "Android" },
  { value: "design", label: "Interaction design" },
];

export default function WithAnError() {
  const [skills, setSkills] = useState<string[]>([]);
  const messageId = useId();
  const error = skills.length === 0 ? "Choose at least one skill for the sprint." : undefined;

  return (
    <Stack gap={1} style={{ maxWidth: 360 }}>
      <MultiSelect
        aria-label="Skills"
        value={skills}
        onChange={setSkills}
        options={SKILLS}
        placeholder="Choose skills"
        invalid={error !== undefined}
        aria-describedby={error ? messageId : undefined}
      />
      {error && (
        <Text id={messageId} size="xs" style={{ color: "var(--u-color-danger-text)" }}>
          {error}
        </Text>
      )}
    </Stack>
  );
}
```

### Filter incidents by service

An empty value means no filter, and the cross takes the field back there in one click; the panel's search, select-all and chosen view help once the list is long.

```tsx
import { useState } from "react";
import { Badge, FormField, MultiSelect, Stack, Text } from "@umriss-ui/core";

/* Data from the operations world, written out here so the example runs on its own. */
const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();

interface Service {
  id: string;
  name: string;
  team: string;
  /** 1 is customer-facing and pages at night; 3 waits for the morning. */
  tier: 1 | 2 | 3;
  /** The latency objective: the 95th percentile stays below this, in ms. */
  latencySlo: number;
  /** The availability promised for a month, in per cent. */
  availabilityTarget: number;
}

const SERVICES: readonly Service[] = [
  { id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
  { id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
  { id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
  { id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
  { id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
  { id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
  { id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
  { id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];

interface Incident {
  id: string;
  title: string;
  service: string;
  severity: "SEV1" | "SEV2" | "SEV3";
  opened: number;
  acknowledged?: number;
  resolved?: number;
  /** An engineer's id. */
  assignee: string;
}

const INCIDENTS: readonly Incident[] = [
  { id: "INC-1048", title: "Checkout slow, card payments time out", service: "checkout", severity: "SEV1", opened: at(17, 9, 42), acknowledged: at(17, 9, 46), assignee: "jonas" },
  { id: "INC-1047", title: "Webhook deliveries delayed", service: "webhooks", severity: "SEV3", opened: at(17, 7, 15), acknowledged: at(17, 8, 2), assignee: "ines" },
  { id: "INC-1046", title: "Thumbnails missing for new uploads", service: "images", severity: "SEV2", opened: at(16, 22, 5), acknowledged: at(16, 22, 11), resolved: at(17, 0, 40), assignee: "tomasz" },
  { id: "INC-1045", title: "Sign-in codes arrive late", service: "sign-in", severity: "SEV2", opened: at(16, 14, 20), acknowledged: at(16, 14, 24), resolved: at(16, 15, 5), assignee: "ada" },
  { id: "INC-1044", title: "Monthly report export fails", service: "reports", severity: "SEV3", opened: at(15, 10, 0), acknowledged: at(16, 9, 12), resolved: at(16, 11, 30), assignee: "felix" },
  { id: "INC-1043", title: "Search returns no results for some regions", service: "search", severity: "SEV1", opened: at(14, 18, 30), acknowledged: at(14, 18, 33), resolved: at(14, 19, 55), assignee: "leila" },
  { id: "INC-1042", title: "Duplicate reminder e-mails", service: "notifications", severity: "SEV3", opened: at(13, 8, 45), acknowledged: at(13, 9, 30), resolved: at(13, 13, 0), assignee: "sam" },
  { id: "INC-1041", title: "Invoices generated twice", service: "billing", severity: "SEV2", opened: at(12, 16, 10), acknowledged: at(12, 16, 18), resolved: at(12, 18, 40), assignee: "priya" },
];

export default function FilterIncidentsByService() {
  const [services, setServices] = useState<string[]>(["checkout", "billing"]);
  const shown = INCIDENTS.filter((i) => services.length === 0 || services.includes(i.service));

  return (
    <Stack gap={4} style={{ maxWidth: 520 }}>
      <FormField label="Services">
        <MultiSelect
          value={services}
          onChange={setServices}
          placeholder="All services"
          searchPlaceholder="Search services"
          options={SERVICES.map((s) => ({ value: s.id, label: s.name }))}
          clearable
        />
      </FormField>
      <Stack gap={2}>
        {shown.map((incident) => (
          <Stack key={incident.id} direction="row" gap={3} align="center">
            <Badge tone={incident.severity === "SEV1" ? "danger" : "warning"}>{incident.severity}</Badge>
            <Text size="sm" mono style={{ flex: "none" }}>
              {incident.id}
            </Text>
            <Text size="sm">{incident.title}</Text>
          </Stack>
        ))}
        {shown.length === 0 && (
          <Text size="sm" tone="muted">
            No incidents on these services this month.
          </Text>
        )}
      </Stack>
    </Stack>
  );
}
```

## When to use something else

- Exactly one value → Combobox
- A few options that should all stay in view → Checkbox

## Keyboard

| Key | Action |
|---|---|
| `Arrow Left / Arrow Right` | Moves across the chips and the “+N” button in the field. |
| `Backspace` | On the field, removes the last chosen value; on a chip, removes that chip (so does Delete). |
| `Arrow Down / Arrow Up` | In the panel, moves between the search and the rows. |
| `Enter` | In the search, ticks or unticks the first row found. |
| `Space` | Ticks or unticks the focused row. |
| `Escape` | Closes the panel. |

## API

### `MultiSelectProps<T extends string = string>`

| Prop | Type | Default | Description |
|---|---|---|---|
| `options` *required* | `readonly `[`MultiSelectOption`](https://romanhaendler.github.io/umriss-ui/core/multiselect/#type-MultiSelectOption)`<T>[]` | — | The possibilities, in their natural order. The list never re-sorts – not even in the "selected" scope.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/multiselect/#configurator-multiselect), [Chips in the field](https://romanhaendler.github.io/umriss-ui/core/multiselect/#chips-in-the-field), [States](https://romanhaendler.github.io/umriss-ui/core/multiselect/#states) and 5 more |
| `value` *required* | `readonly T[]` | — | The chosen values. Always controlled.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/multiselect/#configurator-multiselect), [Chips in the field](https://romanhaendler.github.io/umriss-ui/core/multiselect/#chips-in-the-field), [States](https://romanhaendler.github.io/umriss-ui/core/multiselect/#states) and 5 more |
| `placeholder` | `string` | — | What stands in the empty field.<br>Shown in: [Chips in the field](https://romanhaendler.github.io/umriss-ui/core/multiselect/#chips-in-the-field), [States](https://romanhaendler.github.io/umriss-ui/core/multiselect/#states), [With an error](https://romanhaendler.github.io/umriss-ui/core/multiselect/#with-an-error) and 3 more |
| `searchPlaceholder` | `string` | — | What stands in the panel's search field.<br>Shown in: [Filter incidents by service](https://romanhaendler.github.io/umriss-ui/core/multiselect/#filter-incidents-by-service) |
| `emptyText` | `string` | — | Text when the search finds nothing.<br>Shown in: [States](https://romanhaendler.github.io/umriss-ui/core/multiselect/#states) |
| `size` | `"sm" \| "md"` | the size of a `ControlSizeProvider` around it, else `"md"` | `sm` for a toolbar and dense forms, `md` otherwise.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/multiselect/#configurator-multiselect) |
| `chars` | `number` | — | The width in characters - room for the chips; the field adds its own padding and chevron. Given, the field is that wide wherever it stands, and never wider than its place. Without it the field fills its place, and is 20 characters wide where the place asks - in a toolbar or a row, room for two short chips and the counter. Either way, choosing and removing values never moves it: the chips that do not fit stand as "+N".<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/multiselect/#configurator-multiselect) |
| `disabled` | `boolean` | `false` | Locks field and panel.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/multiselect/#configurator-multiselect), [States](https://romanhaendler.github.io/umriss-ui/core/multiselect/#states), [Dispatch a tour (Scenarios)](https://romanhaendler.github.io/umriss-ui/core/#dispatch-a-tour) |
| `invalid` | `boolean` | — | Marks the field as invalid. `FormField` sets it itself as soon as it carries an `error` – by hand only necessary without `FormField`.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/multiselect/#configurator-multiselect), [With an error](https://romanhaendler.github.io/umriss-ui/core/multiselect/#with-an-error) |
| `clearable` | `boolean` | `false` | Shows a cross beside the chips that empties the selection, as "Select none" does: a chosen value whose option is disabled stays. The field keeps room for it, chosen values or none, so that it never moves.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/multiselect/#configurator-multiselect), [Filter incidents by service](https://romanhaendler.github.io/umriss-ui/core/multiselect/#filter-incidents-by-service), [A row that holds still (Sizes)](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-that-holds-still) |
| `ref` | `Ref<HTMLDivElement>` | — | Receives the component's root element. From `RefProp`.<br>Shown in: [A row that holds still (Sizes)](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-that-holds-still), [An anchored surface (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor) and 2 more |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| `onChange` *required* | `(value: T[]) => void` | — | Runs on every change of the selection, with the complete new list – not with the one value that moved.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/multiselect/#configurator-multiselect), [Chips in the field](https://romanhaendler.github.io/umriss-ui/core/multiselect/#chips-in-the-field), [States](https://romanhaendler.github.io/umriss-ui/core/multiselect/#states) and 5 more |

Also takes every attribute of `<div>` – without `defaultValue`, `onChange`.

### `MultiSelectOption<T extends string = string>`

| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | `T` | — | What stands in `value` when this row is ticked.<br>Shown in: [States](https://romanhaendler.github.io/umriss-ui/core/multiselect/#states), [Filter incidents by service](https://romanhaendler.github.io/umriss-ui/core/multiselect/#filter-incidents-by-service), [Dispatch a tour (Scenarios)](https://romanhaendler.github.io/umriss-ui/core/#dispatch-a-tour) |
| `label` *required* | `string` | — | What is shown – in the panel and as a chip in the field.<br>Shown in: [States](https://romanhaendler.github.io/umriss-ui/core/multiselect/#states), [Filter incidents by service](https://romanhaendler.github.io/umriss-ui/core/multiselect/#filter-incidents-by-service), [Dispatch a tour (Scenarios)](https://romanhaendler.github.io/umriss-ui/core/#dispatch-a-tour) |
| `disabled` | `boolean` | — | Shows the row but does not let it be ticked. A value already chosen stays chosen: disabled means "do not change", not "gone".<br>Shown in: [States](https://romanhaendler.github.io/umriss-ui/core/multiselect/#states) |
