Siteplane UI

Combobox

Search and choose from known options.

Markdown
"use client";

import { UserIcon } from "lucide-react";
import {
  Combobox,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxPopup,
} from "@/siteplane/components/combobox";
import { Field, FieldLabel } from "@/siteplane/components/field";

const people = [
  "Mara Ellis",
  "Ben Carter",
  "Sofia Nguyen",
  "Priya Shah",
  "Nora Lind",
  "Sam Ortiz",
];

export default function Example() {
  return (
    <Field className="w-full max-w-sm">
      <FieldLabel>Assignee</FieldLabel>
      <Combobox items={people}>
        <ComboboxInput
          className="w-full"
          placeholder="Select a person"
          startAddon={<UserIcon />}
        />
        <ComboboxPopup>
          <ComboboxEmpty>No people found.</ComboboxEmpty>
          <ComboboxList>
            {(person: string) => (
              <ComboboxItem key={person} value={person}>
                {person}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxPopup>
      </Combobox>
    </Field>
  );
}

Installation

pnpm dlx shadcn@4.18.0 add @siteplane/combobox

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

pnpm dlx shadcn@4.18.0 add @siteplane/button @siteplane/form @siteplane/input @siteplane/select

Field is installed with Input.

Usage

import {
  Combobox,
  ComboboxInput,
  ComboboxPopup,
  ComboboxEmpty,
  ComboboxList,
  ComboboxItem,
} from "@/siteplane/components/combobox";

<Combobox items={["Design"]}>
  <ComboboxInput aria-label="Workspace" />
  <ComboboxPopup>
    <ComboboxEmpty>No matching workspaces.</ComboboxEmpty>
    <ComboboxList>
      {(item: string) => (
        <ComboboxItem key={item} value={item}>
          {item}
        </ComboboxItem>
      )}
    </ComboboxList>
  </ComboboxPopup>
</Combobox>;

An item shows its check in the leading slot. For identity rows that lead with an avatar or a logo, such as the people of an assignee picker, set indicatorPosition="end" on ComboboxItem: the row starts with its first child and ends with the check, and its label keeps one line, as on Menu radio items.

Composition

Combobox
├── ComboboxInput
└── ComboboxPopup
    ├── ComboboxStatus (optional)
    ├── ComboboxEmpty
    └── ComboboxList → ComboboxItem

Controlled value and clear

The clear button updates the controlled value. Type a non-matching query to see the empty result.

"use client";

import {
  Combobox,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxPopup,
} from "@/siteplane/components/combobox";
import { useState } from "react";

export default function Example() {
  const [value, setValue] = useState<string | null>("Design");
  return (
    <div className="w-full max-w-sm">
      <Combobox
        items={["Design", "Engineering", "Research"]}
        value={value}
        onValueChange={setValue}
      >
        <ComboboxInput
          aria-label="Workspace"
          placeholder="Find workspace"
          showClear
        />
        <ComboboxPopup>
          <ComboboxEmpty>No matching workspaces.</ComboboxEmpty>
          <ComboboxList>
            {(item: string) => (
              <ComboboxItem key={item} value={item}>
                {item}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxPopup>
      </Combobox>
    </div>
  );
}

Multiple selection

Set multiple and render the selected values as ComboboxChips inside ComboboxChips; each chip includes its own remove button. Use removeProps to give each remove button a specific name. Render the chips through ComboboxValue, as below: a chip that is added fades in, a removed chip fades out where it was, and the chips and the input after it glide to their new place. Reduced motion changes them at once. The field is as tall as an Input of the ComboboxChipsInput's size, and its chips are 20 px at sm, 24 px at md and lg and 32 px at xl. A chip is never wider than the field: a long value ends with an ellipsis and keeps its remove button, and the full value is the label's title, as on a Tag. The check of a selected item draws in when the item is picked and out when it is not, the way the Checkbox check does, and appears instantly under reduced motion.

"use client";

import {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxEmpty,
  ComboboxItem,
  ComboboxList,
  ComboboxPopup,
  ComboboxValue,
} from "@/siteplane/components/combobox";
import { Field, FieldLabel } from "@/siteplane/components/field";

const labels = [
  "Bug",
  "Feature",
  "Design",
  "Docs",
  "Frontend",
  "Backend",
  "Research",
];

export default function Example() {
  return (
    <Field className="w-full max-w-sm">
      <FieldLabel>Labels</FieldLabel>
      <Combobox defaultValue={["Feature", "Frontend"]} items={labels} multiple>
        <ComboboxChips>
          <ComboboxValue>
            {(value: string[]) => (
              <>
                {value.map((label) => (
                  <ComboboxChip
                    key={label}
                    removeProps={{ "aria-label": `Remove ${label}` }}
                  >
                    {label}
                  </ComboboxChip>
                ))}
                <ComboboxChipsInput
                  placeholder={value.length > 0 ? undefined : "Add labels"}
                />
              </>
            )}
          </ComboboxValue>
        </ComboboxChips>
        <ComboboxPopup>
          <ComboboxEmpty>No labels found.</ComboboxEmpty>
          <ComboboxList>
            {(label: string) => (
              <ComboboxItem key={label} value={label}>
                {label}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxPopup>
      </Combobox>
    </Field>
  );
}

Grouped options

Pass groups as items and render them with ComboboxGroup and ComboboxCollection. itemToStringLabel turns each object item into the text shown in the input and used for filtering.

"use client";

import {
  Combobox,
  ComboboxCollection,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxGroupLabel,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxPopup,
} from "@/siteplane/components/combobox";

type Timezone = { label: string; value: string };

const timezones: { value: string; items: Timezone[] }[] = [
  {
    value: "Americas",
    items: [
      { label: "(GMT-8) Los Angeles", value: "America/Los_Angeles" },
      { label: "(GMT-6) Chicago", value: "America/Chicago" },
      { label: "(GMT-5) New York", value: "America/New_York" },
    ],
  },
  {
    value: "Europe",
    items: [
      { label: "(GMT+0) London", value: "Europe/London" },
      { label: "(GMT+1) Berlin", value: "Europe/Berlin" },
      { label: "(GMT+1) Paris", value: "Europe/Paris" },
    ],
  },
  {
    value: "Asia/Pacific",
    items: [
      { label: "(GMT+8) Singapore", value: "Asia/Singapore" },
      { label: "(GMT+9) Tokyo", value: "Asia/Tokyo" },
      { label: "(GMT+10) Sydney", value: "Australia/Sydney" },
    ],
  },
];

export default function Example() {
  return (
    <div className="w-full max-w-sm">
      <Combobox
        items={timezones}
        itemToStringLabel={(timezone: Timezone) => timezone.label}
      >
        <ComboboxInput aria-label="Timezone" placeholder="Select a timezone" />
        <ComboboxPopup>
          <ComboboxEmpty>No timezones found.</ComboboxEmpty>
          <ComboboxList>
            {(group: (typeof timezones)[number]) => (
              <ComboboxGroup key={group.value} items={group.items}>
                <ComboboxGroupLabel>{group.value}</ComboboxGroupLabel>
                <ComboboxCollection>
                  {(timezone: Timezone) => (
                    <ComboboxItem key={timezone.value} value={timezone}>
                      {timezone.label}
                    </ComboboxItem>
                  )}
                </ComboboxCollection>
              </ComboboxGroup>
            )}
          </ComboboxList>
        </ComboboxPopup>
      </Combobox>
    </div>
  );
}

Searchable select

When a long list needs search but the field should look like a Select, render ComboboxTrigger with SelectButton and put an embedded ComboboxInput inside the popup.

"use client";

import { SearchIcon } from "lucide-react";
import {
  Combobox,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxPopup,
  ComboboxTrigger,
  ComboboxValue,
} from "@/siteplane/components/combobox";
import { Field, FieldLabel } from "@/siteplane/components/field";
import { SelectButton } from "@/siteplane/components/select";

const countries = [
  { label: "Australia", value: "au" },
  { label: "Brazil", value: "br" },
  { label: "Canada", value: "ca" },
  { label: "France", value: "fr" },
  { label: "Germany", value: "de" },
  { label: "India", value: "in" },
  { label: "Japan", value: "jp" },
  { label: "Netherlands", value: "nl" },
  { label: "Spain", value: "es" },
  { label: "United Kingdom", value: "gb" },
  { label: "United States", value: "us" },
];

export default function Example() {
  return (
    <Field className="w-full max-w-sm">
      <FieldLabel>Country</FieldLabel>
      <Combobox items={countries}>
        <ComboboxTrigger render={<SelectButton />}>
          <ComboboxValue placeholder="Select country" />
        </ComboboxTrigger>
        <ComboboxPopup aria-label="Select country">
          <div className="p-2">
            <ComboboxInput
              placeholder="Search countries…"
              profile="embedded"
              showTrigger={false}
              startAddon={<SearchIcon />}
            />
          </div>
          <ComboboxEmpty>No countries found.</ComboboxEmpty>
          <ComboboxList>
            {(country: (typeof countries)[number]) => (
              <ComboboxItem key={country.value} value={country}>
                {country.label}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxPopup>
      </Combobox>
    </Field>
  );
}

Variants and disabled

variant belongs to the input. Disabled prevents input interaction.

"use client";

import {
  Combobox,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxPopup,
} from "@/siteplane/components/combobox";
import { Field, FieldLabel } from "@/siteplane/components/field";

const workspaces = ["Design", "Engineering", "Research"];

const inputs: {
  disabled?: boolean;
  label: string;
  variant?: "default" | "elevated" | "inset";
}[] = [
  { label: "default", variant: "default" },
  { label: "elevated", variant: "elevated" },
  { label: "inset", variant: "inset" },
  { label: "disabled", disabled: true },
];

export default function Example() {
  return (
    <div className="grid w-full max-w-sm gap-4">
      {inputs.map(({ disabled, label, variant }) => (
        <Field disabled={disabled} key={label}>
          <FieldLabel>Workspace, {label}</FieldLabel>
          <Combobox items={workspaces}>
            <ComboboxInput placeholder="Find workspace" variant={variant} />
            <ComboboxPopup>
              <ComboboxEmpty>No matching workspaces.</ComboboxEmpty>
              <ComboboxList>
                {(item: string) => (
                  <ComboboxItem key={item} value={item}>
                    {item}
                  </ComboboxItem>
                )}
              </ComboboxList>
            </ComboboxPopup>
          </Combobox>
        </Field>
      ))}
    </div>
  );
}

Sizes

size belongs to the input. Omit it to inherit the provider's control size.

"use client";

import {
  Combobox,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxPopup,
} from "@/siteplane/components/combobox";
import { Field, FieldLabel } from "@/siteplane/components/field";

const workspaces = ["Design", "Engineering", "Research"];

const sizes = [
  { size: "sm", label: "small" },
  { size: "md", label: "medium" },
  { size: "lg", label: "large" },
  { size: "xl", label: "extra large" },
] as const;

export default function Example() {
  return (
    <div className="grid w-full max-w-sm gap-4">
      {sizes.map(({ size, label }) => (
        <Field key={size}>
          <FieldLabel>Workspace, {label}</FieldLabel>
          <Combobox items={workspaces}>
            <ComboboxInput placeholder="Find workspace" size={size} />
            <ComboboxPopup>
              <ComboboxEmpty>No matching workspaces.</ComboboxEmpty>
              <ComboboxList>
                {(item: string) => (
                  <ComboboxItem key={item} value={item}>
                    {item}
                  </ComboboxItem>
                )}
              </ComboboxList>
            </ComboboxPopup>
          </Combobox>
        </Field>
      ))}
    </div>
  );
}

Form integration

Place the combobox in a Field with a name; required blocks submission until a value is chosen and FieldError explains why. For object items, itemToStringValue decides the submitted string.

"use client";

import { Button } from "@/siteplane/components/button";
import {
  Combobox,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxPopup,
} from "@/siteplane/components/combobox";
import { Field, FieldError, FieldLabel } from "@/siteplane/components/field";
import { Form } from "@/siteplane/components/form";

const workspaces = ["Design", "Engineering", "Research"];

export default function Example() {
  return (
    <Form
      className="grid w-full max-w-sm gap-3"
      onSubmit={(event) => {
        event.preventDefault();
      }}
    >
      <Field name="workspace">
        <FieldLabel>Default workspace</FieldLabel>
        <Combobox items={workspaces} required>
          <ComboboxInput className="w-full" placeholder="Find workspace" />
          <ComboboxPopup>
            <ComboboxEmpty>No matching workspaces.</ComboboxEmpty>
            <ComboboxList>
              {(item: string) => (
                <ComboboxItem key={item} value={item}>
                  {item}
                </ComboboxItem>
              )}
            </ComboboxList>
          </ComboboxPopup>
        </Combobox>
        <FieldError>Choose a default workspace.</FieldError>
      </Field>
      <Button type="submit">Save</Button>
    </Form>
  );
}

API reference

Component properties
PropTypeDefaultDescription
itemToStringLabel(item: Value) => stringlabel for { value, label } itemsConvert an object item to input display and filtering text.
itemToStringValue(item: Value) => stringvalue for { value, label } itemsConvert an object item to its native form submission value; does not set display text.
ComboboxInput.sizesm | md | lg | xl | numberprovider / lgVisual size, or numeric native input size.
ComboboxInput.variantdefault | elevated | insetdefaultInput surface.
ComboboxInput.showTriggerbooleantrueShow popup toggle.
ComboboxInput.showClearbooleanfalseShow clear action.
ComboboxInput.startAddonReactNodeunsetLeading content.
ComboboxInput.triggerProps / clearPropsTrigger.Props / Clear.PropsunsetConfigure generated action buttons.
ComboboxPopup.side / alignPositioner side / alignmentbottom / startPreferred position.
ComboboxPopup.sideOffset / alignOffsetoffsets4 / unsetGap and alignment correction.
ComboboxPopup.anchorPositioner anchortrigger/input or chips for multipleCustom anchor.
ComboboxPopup.portalPropsPortal.PropsunsetPortal options.
ComboboxItem.indicatorPositionstart | endstartWhere the check shows: in the leading slot, or at the end of an identity row that leads with an avatar or a logo.
value / defaultValueselected Value or Value[]uncontrolledSelection
onValueChange(value, eventDetails) => voidunsetValue callback.
onOpenChange(open, eventDetails) => voidunsetPopup visibility callback.
ComboboxInput.profileComboboxInputProfile: default | embeddeddefaultStandard or embedded input surface.
ComboboxChips.layoutinput | standaloneinputInput surface, or a surface-free chip list below a separate ComboboxInput.
ComboboxChips.startAddonReactNodeunsetLeading chip-container content.
ComboboxChips.variantdefault | elevated | insetdefaultChip-container surface.
ComboboxChipsInput.sizecontrol size | numberprovider / lgInput dimensions; the chips field and its chips follow them.
ComboboxChip.removePropsChipRemove.PropsunsetGenerated remove control props.

Combobox preserves Base UI’s Value/Multiple generics and provides its context for chips/input anchoring. ComboboxInputProps omits the foundation render prop; use the documented embedded profile or public composition parts instead. ComboboxInputProfile is exported. ComboboxChips, Chip, ChipsInput and ChipRemove wrap the matching foundation parts; Chip supplies its own removal action by default. ComboboxContext exports chipsRef/inputRef (null) and multiple (false) defaults for the composition. Item forwards pointer events through its touch-selection guards. inputValue/onInputValueChange control the query independently from selected value. useComboboxFilter is the foundation filtering hook.

ComboboxList renders the filtered collection; Item represents a suggestion; Empty displays no results; Status provides status feedback. ComboboxGroup, ComboboxGroupLabel, ComboboxSeparator and ComboboxRow structure options. ComboboxValue renders the current value; ComboboxCollection supplies foundation collection composition. ComboboxTrigger and ComboboxClear are independently exported actions; their default render uses kit Buttons, and ComboboxTrigger also accepts render={<SelectButton />} for a select-style trigger. Popup includes Portal and Positioner; ComboboxList adds the ScrollArea. ComboboxPrimitive exports the namespace. Remaining root item-to-string/filter, keyboard, validation, disabled, readOnly, form, events and refs follow Base UI Combobox. Styled className props are strings. No independent fetch or loading service is supplied.

ComboboxTrigger and ComboboxClear accept their corresponding foundation props and default to a ghost Button with field-action profile and icon-xs size. Supply an accessible name when composing either action separately. ComboboxGroup, ComboboxGroupLabel, ComboboxRow, ComboboxSeparator, ComboboxValue, and ComboboxCollection accept their matching foundation part props with kit styling; they add no owned state API.

ComboboxChipRemove accepts foundation ChipRemove props and defaults to a bare icon-2xs text Button with an X icon and the accessible label “Remove”. Override the label through removeProps on ComboboxChip for additional context. Do not add a second removal button: Chip already renders this part.

Read the installed source.

Usage notes

Arrow keys navigate suggestions, Enter accepts and Escape dismisses. Keep a visible label in a production form. For async data, the application owns fetching, stale-result handling and pending/error status; render those states through Status and the surrounding field. See Choosing selection controls.