Combobox

One choice from a long list, found by typing (autocomplete, typeahead, searchable select): a driver, a service, an account. The list filters along while the value stays exactly one.

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

What is typed is a search term, not a value: the value changes only when a row is chosen or the field is cleared, and it is one option or null.

The list keeps its given order while it filters; sort the options before you pass them if you want an order.

Examples

Combobox

Typing filters the options; onChange runs only when a row is chosen or cleared, with one value or null.

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

const DRIVERS = [
  { value: "d1", label: "Martin Hale" },
  { value: "d2", label: "Nadia Petrova" },
  { value: "d3", label: "Owen Carter" },
  { value: "d4", label: "Lucia Romero" },
  { value: "d5", label: "Ben Adeyemi" },
  { value: "d6", label: "Hanna Berg" },
  { value: "d7", label: "Yusuf Demir" },
  { value: "d8", label: "Clara Wendt" },
];

export default function PickADriver() {
  const [driver, setDriver] = useState<string | null>(null);

  return (
    <Stack gap={3} style={{ maxWidth: 320 }}>
      <FormField label="Driver, tour T-04">
        <Combobox value={driver} onChange={setDriver} clearable options={DRIVERS} placeholder="Type a name" />
      </FormField>
      <Text size="xs" tone="muted" mono>
        value: {driver === null ? "null" : `"${driver}"`}
      </Text>
    </Stack>
  );
}

States

Set emptyText for a search that finds nothing; errors come from the FormField, and a disabled row stays in the list.

import { Combobox, FormField, Grid, type ComboboxOption } from "@umriss-ui/core";

const VEHICLES: ComboboxOption[] = [
  { value: "v1", label: "FP 214 K, van, North depot" },
  { value: "v2", label: "FP 377 K, e-van, North depot" },
  { value: "v3", label: "FP 118 R, truck, North depot, in the workshop until Thursday", disabled: true },
  { value: "v4", label: "FP 402 R, van, Riverside depot" },
];

export default function States() {
  return (
    <Grid minItemWidth="240px" gap={4}>
      <FormField label="Vehicle" hint="Type “zz” to see the empty result.">
        <Combobox value={null} onChange={() => {}} options={VEHICLES} emptyText="No vehicle matches. Check the plate." />
      </FormField>
      <FormField label="Vehicle" error="Tour T-04 needs a vehicle before it can be released.">
        <Combobox value={null} onChange={() => {}} options={VEHICLES} placeholder="Choose a vehicle" />
      </FormField>
      <FormField label="Vehicle" hint="The tour is on the road.">
        <Combobox value="v2" onChange={() => {}} options={VEHICLES} disabled />
      </FormField>
      <FormField label="Vehicle" hint="A long label is cut at the field's edge.">
        <Combobox value="v3" onChange={() => {}} options={VEHICLES} />
      </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 { Combobox, Stack, Text } from "@umriss-ui/core";

const VEHICLES = [
  { value: "v1", label: "FP 214 K, van, North depot" },
  { value: "v2", label: "FP 377 K, e-van, North depot" },
  { value: "v4", label: "FP 402 R, van, Riverside depot" },
];

export default function WithAnError() {
  const [vehicle, setVehicle] = useState<string | null>(null);
  const messageId = useId();
  const error = vehicle === null ? "Tour T-04 needs a vehicle before it can be released." : undefined;

  return (
    <Stack gap={1} style={{ maxWidth: 360 }}>
      <Combobox
        aria-label="Vehicle"
        value={vehicle}
        onChange={setVehicle}
        options={VEHICLES}
        placeholder="Choose a vehicle"
        invalid={error !== undefined}
        aria-describedby={error ? messageId : undefined}
      />
      {error && (
        <Text id={messageId} size="xs" style={{ color: "var(--u-color-danger-text)" }}>
          {error}
        </Text>
      )}
    </Stack>
  );
}

Assign an incident

Build options from your records, with the team in the label so typing finds either; one choice decides the next.

import { useState } from "react";
import { Button, Card, CardBody, CardHeader, Combobox, FormField, Stack } from "@umriss-ui/core";

/* Data from the operations world, written out here so the example runs on its own. */
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 Engineer {
  id: string;
  name: string;
  team: string;
}

const ENGINEERS: readonly Engineer[] = [
  { id: "priya", name: "Priya Raman", team: "Payments" },
  { id: "jonas", name: "Jonas Keller", team: "Payments" },
  { id: "ada", name: "Ada Mwangi", team: "Identity" },
  { id: "tomasz", name: "Tomasz Nowak", team: "Discovery" },
  { id: "leila", name: "Leila Haddad", team: "Discovery" },
  { id: "sam", name: "Sam Okafor", team: "Messaging" },
  { id: "ines", name: "Ines Duarte", team: "Integrations" },
  { id: "felix", name: "Felix Brandt", team: "Insights" },
];

export default function AssignAnIncident() {
  const [service, setService] = useState<string | null>("checkout");
  const [owner, setOwner] = useState<string | null>(null);
  const team = SERVICES.find((s) => s.id === service)?.team;

  return (
    <Card style={{ maxWidth: 480 }}>
      <CardHeader eyebrow="INC-1049 · SEV2" title="Assign the incident" />
      <CardBody>
        <Stack gap={4}>
          <FormField label="Service">
            <Combobox
              value={service}
              onChange={(next) => {
                setService(next);
                setOwner(null);
              }}
              options={SERVICES.map((s) => ({ value: s.id, label: `${s.name} · ${s.team}` }))}
            />
          </FormField>
          <FormField label="Incident lead" hint={team ? `Engineers of ${team} first.` : undefined}>
            <Combobox
              value={owner}
              onChange={setOwner}
              clearable
              placeholder="Type a name or team"
              options={[...ENGINEERS]
                .sort((a, b) => Number(b.team === team) - Number(a.team === team))
                .map((e) => ({ value: e.id, label: `${e.name} · ${e.team}` }))}
            />
          </FormField>
          <Button variant="primary" size="sm" disabled={owner === null} style={{ alignSelf: "flex-start" }}>
            Assign
          </Button>
        </Stack>
      </CardBody>
    </Card>
  );
}

When to use something else

Keyboard

Key Action
Arrow Down / Arrow Up Opens the list, then moves through the rows.
Enter Chooses the active row.
Escape Closes the list.

API

ComboboxProps<T extends string = string>

NameTypeDefaultDescription
optionsrequiredreadonly ComboboxOption<T>[]—The possibilities, in their natural order. Filtered while typing; never reordered.

Shown in: Configurator, Combobox, States and 5 more

valuerequiredT | null—The chosen value; null means none. Always controlled - the field keeps no second state beside the caller's.

Shown in: Configurator, Combobox, States and 5 more

placeholderstring—What stands in the empty field.

Shown in: Combobox, States, With an error and 3 more

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 typed text and the chosen option; the field adds its own padding, chevron and cross. 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. The panel is never narrower than the field.

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

emptyTextstring—Text shown for an empty search result.

Shown in: States

clearablebooleanfalseShows a cross on selection or input that clears field and selection.

Shown in: Configurator, Combobox, Assign an incident and 2 more

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 | null) => void—Runs on choosing and on clearing (null). Not while typing: what is typed is a search term and not yet a value.

Shown in: Configurator, Combobox, States and 5 more

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

ComboboxOption<T extends string = string>

NameTypeDefaultDescription
valuerequiredT—What comes back when this row is chosen.

Shown in: States, Assign an incident, Dispatch a tour (Scenarios) and 1 more

labelrequiredstring—What stands there - and what is filtered on. Text and not a node: what cannot be searched is something nobody finds in a combobox.

Shown in: States, Assign an incident, Dispatch a tour (Scenarios) and 1 more

disabledboolean—Shows the row but does not let it be chosen.

Shown in: States

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/core

Getting started

Customising

Layout

Typography

Actions

Forms

Choosing

Dates and times

Feedback

Overlays

Navigation

Data display

API index