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.
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>
);
}
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>
);
}
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>
);
}
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>
);
}
| 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. |
MultiSelectProps<T extends string = string>| Name | Type | Default | Description |
|---|---|---|---|
optionsrequired | readonly 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 |
valuerequired | readonly T[] | — | The chosen values. Always controlled. Shown in: Configurator, Chips in the field, States and 5 more |
placeholder | string | — | What stands in the empty field. Shown in: Chips in the field, States, With an error and 3 more |
searchPlaceholder | string | — | What stands in the panel's search field. Shown in: Filter incidents by service |
emptyText | string | — | 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 |
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". Shown in: Configurator |
disabled | boolean | false | Locks field and panel. Shown in: Configurator, States, Dispatch a tour (Scenarios) |
invalid | boolean | — | 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 |
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. Shown in: Configurator, Filter incidents by service, A row that holds still (Sizes) |
ref | Ref<HTMLDivElement> | — | Receives the component's root element. from RefPropShown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more |
| Name | Type | Default | Description |
|---|---|---|---|
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>| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | T | — | What stands in value when this row is ticked.Shown in: States, Filter incidents by service, Dispatch a tour (Scenarios) |
labelrequired | string | — | What is shown – in the panel and as a chip in the field. Shown in: States, Filter incidents by service, Dispatch a tour (Scenarios) |
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". Shown in: States |