MultiSelect

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.

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

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).

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.

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.

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.

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.

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

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>

NameTypeDefaultDescription
optionsrequiredreadonly MultiSelectOption<T>[]—The possibilities, in their natural order. The list never re-sorts – not even in the "selected" scope.

Shown in: Configurator, Chips in the field, States and 5 more

valuerequiredreadonly T[]—The chosen values. Always controlled.

Shown in: Configurator, Chips in the field, States and 5 more

placeholderstring—What stands in the empty field.

Shown in: Chips in the field, States, With an error and 3 more

searchPlaceholderstring—What stands in the panel's search field.

Shown in: Filter incidents by service

emptyTextstring—Text when the search finds nothing.

Shown in: States

size"sm" | "md"the size of a ControlSizeProvider around it, else "md"sm for a toolbar and dense forms, md otherwise.

Shown in: Configurator

charsnumber—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".

Shown in: Configurator

disabledbooleanfalseLocks field and panel.

Shown in: Configurator, States, Dispatch a tour (Scenarios)

invalidboolean—Marks the field as invalid. FormField sets it itself as soon as it carries an error – by hand only necessary without FormField.

Shown in: Configurator, With an error

clearablebooleanfalseShows 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.

Shown in: Configurator, Filter incidents by service, A row that holds still (Sizes)

refRef<HTMLDivElement>—Receives the component's root element. from RefProp

Shown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more

Events

NameTypeDefaultDescription
onChangerequired(value: T[]) => void—Runs on every change of the selection, with the complete new list – not with the one value that moved.

Shown in: Configurator, Chips in the field, States and 5 more

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

MultiSelectOption<T extends string = string>

NameTypeDefaultDescription
valuerequiredT—What stands in value when this row is ticked.

Shown in: States, Filter incidents by service, Dispatch a tour (Scenarios)

labelrequiredstring—What is shown – in the panel and as a chip in the field.

Shown in: States, Filter incidents by service, Dispatch a tour (Scenarios)

disabledboolean—Shows the row but does not let it be ticked. A value already chosen stays chosen: disabled means "do not change", not "gone".

Shown in: States

Every page of @umriss-ui/core

Getting started

Customising

Layout

Typography

Actions

Forms

Choosing

Dates and times

Feedback

Overlays

Navigation

Data display

API index