Skip to content
Lenso UI

ComboBox

A combo box combines a text input with a listbox, allowing users to filter a list of options to items matching a query

Usage

import { ComboBox } from '@lenso/ui';
"use client";// HeroUI v3.2.6, Apache-2.0. Native Base UI adaptation.import { AnimalPicker } from "./shared";export function Default() {  return <AnimalPicker />;}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Anatomy

import { ComboBox, Input, Label, Description, Header, ListBox, Separator } from '@lenso/ui';
export default () => (  <ComboBox>    <Label />    <ComboBox.InputGroup>      <Input />      <ComboBox.Trigger />    </ComboBox.InputGroup>    {/* Displays the selected values, primarily used for multiple selection */}    <ComboBox.Value />    <Description />    <ComboBox.Popover>      <ListBox>        <ListBox.Item>          <Label />          <Description />          <ListBox.ItemIndicator />        </ListBox.Item>        <ListBox.Section>          <Header />          <ListBox.Item>            <Label />          </ListBox.Item>        </ListBox.Section>      </ListBox>    </ComboBox.Popover>  </ComboBox>)

Examples

Full Width

"use client";// HeroUI v3.2.6, Apache-2.0.import * as stylex from "@stylexjs/stylex";import { AnimalPicker, animals } from "./shared";import { styles } from "./styles.stylex";export function FullWidth() {  return (    <div {...stylex.props(styles.wide)}>      <AnimalPicker fullWidth items={animals.slice(0, 3)} />    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

With Description

"use client";// HeroUI v3.2.6, Apache-2.0.import { AnimalPicker } from "./shared";export function WithDescription() {  return <AnimalPicker description="Search and select your favorite animal" />;}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Required

"use client";// HeroUI v3.2.6, Apache-2.0. Base Field context owns validation and error linkage.import { Button, FieldError, Form, TextField } from "@lenso/ui";import { AnimalPicker } from "./shared";import { styles } from "./styles.stylex";
export function AnimalForm({ secondary = false }: { secondary?: boolean }) {  return (    <Form      xstyle={[styles.form, secondary && styles.fullWidth]}      onSubmit={(event) => {        event.preventDefault();        new FormData(event.currentTarget);        alert("Form submitted successfully!");      }}    >      <TextField name="animal">        <AnimalPicker required name="animal" fullWidth secondary={secondary} />        <FieldError />      </TextField>      <Button type="submit">Submit</Button>    </Form>  );}export function Required() {  return <AnimalForm />;}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Disabled

"use client";// HeroUI v3.2.6, Apache-2.0.import { AnimalPicker, animals } from "./shared";export function Disabled() {  return <AnimalPicker disabled defaultValue={animals[1]} />;}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

With Disabled Options

"use client";// HeroUI v3.2.6, Apache-2.0.import { AnimalPicker } from "./shared";const animals = [  { id: "dog", name: "Dog" },  { id: "cat", name: "Cat" },  { id: "bird", name: "Bird" },  { id: "kangaroo", name: "Kangaroo" },  { id: "elephant", name: "Elephant" },  { id: "tiger", name: "Tiger" },];export function WithDisabledOptions() {  return <AnimalPicker label="Animal" items={animals} disabledIds={["cat", "kangaroo"]} />;}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

With Sections

"use client";// HeroUI v3.2.6, Apache-2.0. Native grouped collection filters without losing section semantics.import { ComboBox } from "@lenso/ui";import * as stylex from "@stylexjs/stylex";import { useId } from "react";import { styles } from "./styles.stylex";import { useFocusMenu } from "./shared";const regions = [  {    label: "North America",    items: [      { value: "usa", label: "United States" },      { value: "canada", label: "Canada" },      { value: "mexico", label: "Mexico" },    ],  },  {    label: "Europe",    items: [      { value: "uk", label: "United Kingdom" },      { value: "france", label: "France" },      { value: "germany", label: "Germany" },      { value: "spain", label: "Spain" },      { value: "italy", label: "Italy" },    ],  },  {    label: "Asia",    items: [      { value: "japan", label: "Japan" },      { value: "china", label: "China" },      { value: "india", label: "India" },      { value: "south-korea", label: "South Korea" },    ],  },];export function WithSections() {  const id = useId();  const menu = useFocusMenu();  return (    <div {...stylex.props(styles.field)}>      <ComboBox {...menu.root} items={regions}>        <ComboBox.Label htmlFor={id}>Country</ComboBox.Label>        <ComboBox.InputGroup>          <ComboBox.Input {...menu.input} id={id} placeholder="Search countries..." />          <ComboBox.Trigger aria-label="Show countries">            <ComboBox.Indicator />          </ComboBox.Trigger>        </ComboBox.InputGroup>        <ComboBox.Portal>          <ComboBox.Positioner>            <ComboBox.Popover>              <ComboBox.List>                {(region: (typeof regions)[number], index: number) => (                  <ComboBox.Group key={region.label} items={region.items}>                    {index > 0 && <ComboBox.Separator />}                    <ComboBox.GroupLabel>{region.label}</ComboBox.GroupLabel>                    <ComboBox.Collection>                      {(country: (typeof region.items)[number]) => (                        <ComboBox.Item key={country.value} value={country}>                          {country.label}                          <ComboBox.ItemIndicator />                        </ComboBox.Item>                      )}                    </ComboBox.Collection>                  </ComboBox.Group>                )}              </ComboBox.List>            </ComboBox.Popover>          </ComboBox.Positioner>        </ComboBox.Portal>      </ComboBox>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Controlled

"use client";// HeroUI v3.2.6, Apache-2.0.import { useState } from "react";import * as stylex from "@stylexjs/stylex";import { AnimalPicker, smallAnimals, type Animal } from "./shared";import { styles } from "./styles.stylex";export function Controlled() {  const [selectedAnimal, setSelectedAnimal] = useState<Animal | null>(smallAnimals[0] ?? null);  return (    <div {...stylex.props(styles.column)}>      <AnimalPicker        label="Animal (controlled)"        items={smallAnimals}        value={selectedAnimal}        onValueChange={setSelectedAnimal}      />      <p {...stylex.props(styles.muted)}>Selected: {selectedAnimal?.name || "None"}</p>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Controlled Input Value

"use client";// HeroUI v3.2.6, Apache-2.0.import { useState } from "react";import * as stylex from "@stylexjs/stylex";import { AnimalPicker } from "./shared";import { styles } from "./styles.stylex";export function ControlledInputValue() {  const [inputValue, setInputValue] = useState("");  return (    <div {...stylex.props(styles.column)}>      <AnimalPicker        label="Search (controlled input)"        placeholder="Type to search..."        inputValue={inputValue}        onInputValueChange={setInputValue}      />      <p {...stylex.props(styles.muted)}>Input value: {inputValue || "(empty)"}</p>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Asynchronous Loading

"use client";/** * HeroUI v3.2.6, Apache-2.0. * Modified: abortable fetch and an intersection sentinel replace React Stately's async collection. */import { ComboBox, Spinner } from "@lenso/ui";import * as stylex from "@stylexjs/stylex";import { useEffect, useId, useRef, useState } from "react";import { styles } from "./styles.stylex";import { useFocusMenu } from "./shared";
interface Character {  name: string;}interface Page {  next: string | null;  results: Character[];}export function AsynchronousLoading() {  const id = useId();  const menu = useFocusMenu();  const [inputValue, setInputValue] = useState("");  const [items, setItems] = useState<Character[]>([]);  const [cursor, setCursor] = useState<string | null>(null);  const [loading, setLoading] = useState(false);  const [error, setError] = useState<string | null>(null);  const [nextPage, setNextPage] = useState<string | null>(null);  const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);  const generation = useRef(0);
  useEffect(() => {    const controller = new AbortController();    const current = ++generation.current;    setItems([]);    setCursor(null);    setNextPage(null);    setLoading(true);    setError(null);    fetch(`https://swapi.py4e.com/api/people/?search=${encodeURIComponent(inputValue)}`, {      signal: controller.signal,    })      .then(async (response) => {        if (!response.ok) throw new Error(`Request failed (${response.status})`);        return (await response.json()) as Page;      })      .then((page) => {        if (current !== generation.current) return;        setItems(page.results);        setCursor(page.next);      })      .catch((reason: unknown) => {        if (!controller.signal.aborted)          setError(reason instanceof Error ? reason.message : "Unable to load characters");      })      .finally(() => {        if (current === generation.current && !controller.signal.aborted) setLoading(false);      });    return () => controller.abort();  }, [inputValue]);
  useEffect(() => {    if (!nextPage) return;    const controller = new AbortController();    const current = generation.current;    setLoading(true);    setError(null);    fetch(nextPage.replace(/^http:\/\//i, "https://"), { signal: controller.signal })      .then(async (response) => {        if (!response.ok) throw new Error(`Request failed (${response.status})`);        return (await response.json()) as Page;      })      .then((page) => {        if (current !== generation.current) return;        setItems((previous) => [...previous, ...page.results]);        setCursor(page.next);      })      .catch((reason: unknown) => {        if (!controller.signal.aborted)          setError(reason instanceof Error ? reason.message : "Unable to load characters");      })      .finally(() => {        if (current === generation.current && !controller.signal.aborted) {          setLoading(false);          setNextPage(null);        }      });    return () => controller.abort();  }, [nextPage]);
  useEffect(() => {    if (!sentinel || !cursor || loading || error) return;    const observer = new IntersectionObserver((entries) => {      if (entries.some((entry) => entry.isIntersecting)) setNextPage(cursor);    });    observer.observe(sentinel);    return () => observer.disconnect();  }, [sentinel, cursor, loading, error]);
  return (    <div {...stylex.props(styles.field)}>      <ComboBox<Character>        {...menu.root}        items={items}        filter={null}        inputValue={inputValue}        onInputValueChange={setInputValue}        itemToStringLabel={(character) => character.name}        itemToStringValue={(character) => character.name}      >        <ComboBox.Label htmlFor={id}>Pick a Character</ComboBox.Label>        <ComboBox.InputGroup>          <ComboBox.Input {...menu.input} id={id} placeholder="Star Wars characters..." />          <ComboBox.Trigger aria-label="Show characters">            <ComboBox.Indicator />          </ComboBox.Trigger>        </ComboBox.InputGroup>        <ComboBox.Portal>          <ComboBox.Positioner>            <ComboBox.Popover>              <ComboBox.List aria-busy={loading}>                {(character: Character) => (                  <ComboBox.Item key={character.name} value={character}>                    {character.name}                    <ComboBox.ItemIndicator />                  </ComboBox.Item>                )}              </ComboBox.List>              <ComboBox.Empty>                {loading ? "Loading..." : (error ?? "No results found")}              </ComboBox.Empty>              <div ref={setSentinel} {...stylex.props(styles.loading)}>                {loading && (                  <>                    <Spinner size="sm" />                    <span {...stylex.props(styles.muted)}>                      {items.length ? "Loading more..." : "Loading..."}                    </span>                  </>                )}                {cursor && !loading && (                  <button type="button" onClick={() => setNextPage(cursor)}>                    Load more                  </button>                )}                {error && items.length > 0 && <span role="alert">{error}</span>}              </div>            </ComboBox.Popover>          </ComboBox.Positioner>        </ComboBox.Portal>      </ComboBox>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Default Selected Key

"use client";// HeroUI v3.2.6, Apache-2.0. Native defaultValue replaces defaultSelectedKey.import { AnimalPicker, animals } from "./shared";export function DefaultSelectedKey() {  return <AnimalPicker defaultValue={animals[1]} />;}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Allows Custom Value

"use client";// HeroUI v3.2.6, Apache-2.0. Native input retains free text independently of selection.import { AnimalPicker } from "./shared";export function AllowsCustomValue() {  return (    <AnimalPicker      placeholder="Search or type an animal..."      description="You can type any animal name, even if it's not in the list"    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Custom Indicator

"use client";// HeroUI v3.2.6, Apache-2.0.import { ChevronsExpandVertical } from "@gravity-ui/icons";import * as stylex from "@stylexjs/stylex";import { AnimalPicker } from "./shared";import { styles } from "./styles.stylex";export function CustomIndicator() {  return (    <AnimalPicker      indicator={<ChevronsExpandVertical aria-hidden {...stylex.props(styles.icon)} />}    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Custom Value

"use client";// HeroUI v3.2.6, Apache-2.0. Source user data and avatar composition retained.import { Avatar, AvatarImage, AvatarFallback, ComboBox } from "@lenso/ui";import * as stylex from "@stylexjs/stylex";import { useId } from "react";import { styles } from "./styles.stylex";import { useFocusMenu } from "./shared";const users = [  {    avatarUrl: "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/blue.jpg",    email: "[email protected]",    fallback: "B",    id: "1",    name: "Bob",  },  {    avatarUrl: "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/green.jpg",    email: "[email protected]",    fallback: "F",    id: "2",    name: "Fred",  },  {    avatarUrl: "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/purple.jpg",    email: "[email protected]",    fallback: "M",    id: "3",    name: "Martha",  },  {    avatarUrl: "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/red.jpg",    email: "[email protected]",    fallback: "J",    id: "4",    name: "John",  },  {    avatarUrl: "https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/orange.jpg",    email: "[email protected]",    fallback: "J",    id: "5",    name: "Jane",  },];export function CustomValue() {  const id = useId();  const menu = useFocusMenu();  return (    <div {...stylex.props(styles.field)}>      <ComboBox<(typeof users)[number]>        {...menu.root}        items={users}        itemToStringLabel={(user) => user.name}        itemToStringValue={(user) => user.id}      >        <ComboBox.Label htmlFor={id}>User</ComboBox.Label>        <ComboBox.InputGroup>          <ComboBox.Input {...menu.input} id={id} placeholder="Search users..." />          <ComboBox.Trigger aria-label="Show users">            <ComboBox.Indicator />          </ComboBox.Trigger>        </ComboBox.InputGroup>        <ComboBox.Portal>          <ComboBox.Positioner>            <ComboBox.Popover>              <ComboBox.List>                {(user: (typeof users)[number]) => (                  <ComboBox.Item key={user.id} value={user}>                    <Avatar size="sm">                      <AvatarImage src={user.avatarUrl} alt="" />                      <AvatarFallback>{user.fallback}</AvatarFallback>                    </Avatar>                    <div {...stylex.props(styles.user)}>                      <span>{user.name}</span>                      <span {...stylex.props(styles.muted)}>{user.email}</span>                    </div>                    <ComboBox.ItemIndicator />                  </ComboBox.Item>                )}              </ComboBox.List>            </ComboBox.Popover>          </ComboBox.Positioner>        </ComboBox.Portal>      </ComboBox>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Custom Filtering

"use client";// HeroUI v3.2.6, Apache-2.0.import { AnimalPicker, smallAnimals } from "./shared";export function CustomFiltering() {  return (    <AnimalPicker      label="Animal (custom filter)"      items={smallAnimals}      filter={(animal, inputValue) =>        !inputValue || animal.name.toLowerCase().includes(inputValue.toLowerCase())      }    />  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Render Function

"use client";// HeroUI v3.2.6, Apache-2.0. Base UI Root is non-DOM; compose its native InputGroup.import { AnimalPicker } from "./shared";export function RenderFunction() {  return <AnimalPicker composed />;}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Use the menuTrigger prop to control when the popover opens:

  • focus (default): popover opens when the user focuses the input
  • input: popover opens when the user edits the input text
  • manual: popover only opens when the user presses the trigger button or uses the arrow keys
"use client";// HeroUI v3.2.6, Apache-2.0. Opening policies use native controlled open events.import { useState } from "react";import * as stylex from "@stylexjs/stylex";import { AnimalPicker } from "./shared";import { styles } from "./styles.stylex";
function Policy({ mode }: { mode: "focus" | "input" | "manual" }) {  const [open, setOpen] = useState(false);  const description = {    focus: "Popover opens when the input is focused",    input: "Popover opens when the user edits the input text",    manual: "Popover only opens when the trigger button is pressed or arrow keys are used",  };  return (    <div {...stylex.props(styles.column)}>      <p {...stylex.props(styles.caption)}>        {mode === "focus" ? "Focus (default)" : mode === "input" ? "Input" : "Manual"}      </p>      <AnimalPicker        open={open}        openOnInputClick={mode === "focus"}        inputFocus={mode === "focus" ? () => setOpen(true) : undefined}        onOpenChange={(next, details) => {          if (next && mode === "manual" && details.reason === "input-change") return;          setOpen(next);        }}        description={description[mode]}      />    </div>  );}export function MenuTrigger() {  return (    <div {...stylex.props(styles.menu)}>      <Policy mode="focus" />      <Policy mode="input" />      <Policy mode="manual" />    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Multiple Selection

Set selectionMode="multiple" to allow selecting more than one option. In multiple selection mode, use ComboBox.Value to display the selected items and pass selectionMode="multiple" to the inner ListBox as well. Selection is controlled with the value / defaultValue (Key[]) props and the onChange handler.

"use client";// HeroUI v3.2.6, Apache-2.0. Native chips preserve keyboard removal and focus.import { ComboBox } from "@lenso/ui";import { useId } from "react";import * as stylex from "@stylexjs/stylex";import {  animals,  animalLabel,  animalValue,  sameAnimal,  AnimalOptions,  type Animal,  useFocusMenu,} from "./shared";import { styles } from "./styles.stylex";
export function MultipleSelection() {  const id = useId();  const menu = useFocusMenu();  return (    <div {...stylex.props(styles.field)}>      <ComboBox        {...menu.root}        multiple        items={animals}        itemToStringLabel={animalLabel}        itemToStringValue={animalValue}        isItemEqualToValue={sameAnimal}      >        <ComboBox.Label htmlFor={id}>Favorite Animals</ComboBox.Label>        <ComboBox.InputGroup>          <ComboBox.Input {...menu.input} id={id} placeholder="Search animals..." />          <ComboBox.Trigger aria-label="Show animals">            <ComboBox.Indicator />          </ComboBox.Trigger>        </ComboBox.InputGroup>        <ComboBox.Chips>          <ComboBox.Value>            {(selected: Animal[]) =>              selected.length === 0 ? (                <span {...stylex.props(styles.muted)}>No animals selected</span>              ) : (                selected.map((animal) => (                  <ComboBox.Chip key={animal.id}>                    {animal.name}                    <ComboBox.ChipRemove aria-label={`Remove ${animal.name}`}>                      ×                    </ComboBox.ChipRemove>                  </ComboBox.Chip>                ))              )            }          </ComboBox.Value>        </ComboBox.Chips>        <AnimalOptions />      </ComboBox>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

In Surface

When used inside a Surface component, use variant="secondary" to apply the lower emphasis variant suitable for surface backgrounds.

"use client";// HeroUI v3.2.6, Apache-2.0.import { Surface } from "@lenso/ui";import { AnimalForm } from "./required";import { styles } from "./styles.stylex";export function OnSurface() {  return (    <Surface xstyle={styles.surface}>      <AnimalForm secondary />    </Surface>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Customization

Tailwind CSS

"use client";// HeroUI v3.2.6, Apache-2.0. Native highlighted/selected states replace RAC selectors.import { ComboBox } from "@lenso/ui";import * as stylex from "@stylexjs/stylex";import { useId } from "react";import { styles } from "./styles.stylex";import { useFocusMenu } from "./shared";const frameworks = [  { value: "react", label: "React" },  { value: "vue", label: "Vue" },  { value: "svelte", label: "Svelte" },];export function CustomStyles() {  const id = useId();  const menu = useFocusMenu();  return (    <div {...stylex.props(styles.customField)}>      <ComboBox {...menu.root} items={frameworks}>        <ComboBox.Label htmlFor={id} xstyle={styles.customLabel}>          Framework        </ComboBox.Label>        <ComboBox.InputGroup xstyle={styles.customGroup}>          <ComboBox.Input {...menu.input} id={id} placeholder="Search..." />          <ComboBox.Trigger aria-label="Show frameworks" xstyle={styles.muted}>            <ComboBox.Indicator />          </ComboBox.Trigger>        </ComboBox.InputGroup>        <ComboBox.Portal>          <ComboBox.Positioner>            <ComboBox.Popover xstyle={styles.customPopover}>              <ComboBox.List>                {(item: (typeof frameworks)[number]) => (                  <ComboBox.Item key={item.value} value={item} xstyle={styles.customItem}>                    {item.label}                    <ComboBox.ItemIndicator />                  </ComboBox.Item>                )}              </ComboBox.List>            </ComboBox.Popover>          </ComboBox.Positioner>        </ComboBox.Portal>      </ComboBox>    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Global CSS

To customize the ComboBox component classes, you can use the @layer components directive. Learn more.

@layer components {  .combo-box {    @apply flex flex-col gap-1;  }
  .combo-box__input-group {    @apply relative inline-flex items-center;  }
  .combo-box__trigger {    @apply absolute right-0 text-muted;  }
  .combo-box__popover {    @apply rounded-lg border border-border bg-surface p-2;  }}

Styling Reference

HeroUI follows the BEM methodology to ensure component variants and states are reusable and easy to customize.

CSS Classes

The ComboBox component uses these CSS classes (View source styles):

Base Classes [!toc]

  • .combo-box - Base ComboBox container
  • .combo-box__input-group - Container for the input and trigger button
  • .combo-box__value - The selected value display (used in multiple selection)
  • .combo-box__trigger - The button that triggers the popover
  • .combo-box__popover - The popover container

State Classes [!toc]

  • .combo-box[data-invalid="true"] - Invalid state
  • .combo-box[data-disabled="true"] - Disabled ComboBox state
  • .combo-box__trigger[data-focus-visible="true"] - Focused trigger state
  • .combo-box__trigger[data-disabled="true"] - Disabled trigger state
  • .combo-box__trigger[data-open="true"] - Open trigger state

Interactive States

The component supports both CSS pseudo-classes and data attributes for flexibility:

  • Hover: :hover or [data-hovered="true"] on trigger
  • Focus: :focus-visible or [data-focus-visible="true"] on trigger
  • Disabled: :disabled or [data-disabled="true"] on ComboBox
  • Open: [data-open="true"] on trigger

API Reference

ComboBox

PropTypeDefaultDescription
inputValuestring-Current input value (controlled)
defaultInputValuestring-Default input value (uncontrolled)
onInputChange(value: string) => void-Handler called when the input value changes
selectionMode"single" | "multiple""single"Whether single or multiple selection is enabled
selectedKeyKey | null-Current selected key (controlled, single selection)
defaultSelectedKeyKey | null-Default selected key (uncontrolled, single selection)
onSelectionChange(key: Key | null) => void-Handler called when the selection changes (single selection)
valueKey | null | Key[]-The currently selected keys (controlled). Key[] when selectionMode="multiple"
defaultValueKey | null | Key[]-The initial selected keys (uncontrolled). Key[] when selectionMode="multiple"
onChange(value: Key | null | Key[]) => void-Handler called when the selection changes
itemsIterable<T>-The items to display in the listbox
disabledKeysIterable<Key>-Keys of disabled items
defaultFilter(text: string, inputValue: string) => boolean-Custom filter function for filtering items
isDisabledboolean-Whether the ComboBox is disabled
isReadOnlyboolean-Whether the input can be selected but not changed by the user
isRequiredboolean-Whether user input is required
isInvalidboolean-Whether the ComboBox value is invalid
validate(value: ComboBoxValidationValue) => ValidationError | true | null | undefined-A function that returns an error message if a given value is invalid. Validation errors are displayed to the user when the form is submitted if validationBehavior="native". For realtime validation, use the isInvalid prop instead
validationBehavior"native" | "aria""native"Whether to use native HTML form validation to prevent form submission when the value is missing or invalid, or mark the field as required or invalid via ARIA
namestring-The name of the input, used when submitting an HTML form
formstring-The id of a <form> element to associate the input with
formValue"text" | "key""key"Whether the text or key of the selected item is submitted as part of an HTML form. When allowsCustomValue is true, this option does not apply and the text is always submitted
autoCompletestring-Describes the type of autocomplete functionality
autoFocusboolean-Whether the element should receive focus on render
allowsCustomValueboolean-Whether the ComboBox allows custom values not in the list
allowsEmptyCollectionboolean-Whether the ComboBox allows an empty collection
menuTrigger"focus" | "input" | "manual""focus"The interaction required to display the ComboBox menu
shouldFocusWrapboolean-Whether keyboard navigation is circular
fullWidthbooleanfalseWhether the ComboBox should take full width of its container
classNamestring-Additional CSS classes
childrenReactNode | RenderFunction-ComboBox content or render function
renderDOMRenderFunction<keyof React.JSX.IntrinsicElements, ComboBoxRenderProps>-Overrides the default DOM element with a custom render function.

ComboBox.InputGroup

PropTypeDefaultDescription
classNamestring-Additional CSS classes
childrenReactNode-InputGroup content

ComboBox.Value

Renders the selected values of a ComboBox, or a placeholder if no value is selected. By default the selected items are rendered as a comma separated list. Use the render function to customize this (for example, to display tags).

PropTypeDefaultDescription
placeholderReactNode-A value to display when no items are selected
classNamestring | (values: ComboBoxValueRenderProps) => string-Additional CSS classes
childrenReactNode | (values: ComboBoxValueRenderProps) => ReactNode-Custom render function for the selected values

ComboBox.Trigger

PropTypeDefaultDescription
classNamestring-Additional CSS classes
childrenReactNode-Custom trigger content

ComboBox.Popover

PropTypeDefaultDescription
placement"bottom" | "bottom left" | "bottom right" | "bottom start" | "bottom end" | "top" | "top left" | "top right" | "top start" | "top end" | "left" | "left top" | "left bottom" | "start" | "start top" | "start bottom" | "right" | "right top" | "right bottom" | "end" | "end top" | "end bottom""bottom"Placement of the popover relative to the trigger
classNamestring-Additional CSS classes
childrenReactNode-Content children

Render Props

When using render functions with ComboBox, these values are provided:

PropTypeDescription
stateComboBoxStateThe state of the ComboBox
inputValuestringThe current input value
selectedKeyKey | nullThe currently selected key
selectedItemNode | nullThe currently selected item

Examples

Basic Usage

import { ComboBox, Input, Label, ListBox } from '@lenso/ui';
<ComboBox className="w-[256px]">  <Label>Favorite Animal</Label>  <ComboBox.InputGroup>    <Input placeholder="Search animals..." />    <ComboBox.Trigger />  </ComboBox.InputGroup>  <ComboBox.Popover>    <ListBox>      <ListBox.Item id="cat" textValue="Cat">        Cat        <ListBox.ItemIndicator />      </ListBox.Item>      <ListBox.Item id="dog" textValue="Dog">        Dog        <ListBox.ItemIndicator />      </ListBox.Item>    </ListBox>  </ComboBox.Popover></ComboBox>

With Sections

import { ComboBox, Input, Label, ListBox, Header, Separator } from '@lenso/ui';
<ComboBox className="w-[256px]">  <Label>Country</Label>  <ComboBox.InputGroup>    <Input placeholder="Search countries..." />    <ComboBox.Trigger />  </ComboBox.InputGroup>  <ComboBox.Popover>    <ListBox>      <ListBox.Section>        <Header>North America</Header>        <ListBox.Item id="usa" textValue="United States">          United States          <ListBox.ItemIndicator />        </ListBox.Item>      </ListBox.Section>      <Separator />      <ListBox.Section>        <Header>Europe</Header>        <ListBox.Item id="uk" textValue="United Kingdom">          United Kingdom          <ListBox.ItemIndicator />        </ListBox.Item>      </ListBox.Section>    </ListBox>  </ComboBox.Popover></ComboBox>

Controlled Selection

import type { Key } from '@lenso/ui';
import { ComboBox, Input, Label, ListBox } from '@lenso/ui';import { useState } from 'react';
function ControlledComboBox() {  const [selectedKey, setSelectedKey] = useState<Key | null>('cat');
  return (    <ComboBox      className="w-[256px]"      selectedKey={selectedKey}      onSelectionChange={setSelectedKey}    >      <Label>Animal</Label>      <ComboBox.InputGroup>        <Input placeholder="Search animals..." />        <ComboBox.Trigger />      </ComboBox.InputGroup>      <ComboBox.Popover>        <ListBox>          <ListBox.Item id="cat" textValue="Cat">            Cat            <ListBox.ItemIndicator />          </ListBox.Item>          <ListBox.Item id="dog" textValue="Dog">            Dog            <ListBox.ItemIndicator />          </ListBox.Item>        </ListBox>      </ComboBox.Popover>    </ComboBox>  );}

Controlled Input Value

import { ComboBox, Input, Label, ListBox } from '@lenso/ui';import { useState } from 'react';
function ControlledInputComboBox() {  const [inputValue, setInputValue] = useState('');
  return (    <ComboBox      className="w-[256px]"      inputValue={inputValue}      onInputChange={setInputValue}    >      <Label>Search</Label>      <ComboBox.InputGroup>        <Input placeholder="Type to search..." />        <ComboBox.Trigger />      </ComboBox.InputGroup>      <ComboBox.Popover>        <ListBox>          <ListBox.Item id="cat" textValue="Cat">            Cat            <ListBox.ItemIndicator />          </ListBox.Item>          <ListBox.Item id="dog" textValue="Dog">            Dog            <ListBox.ItemIndicator />          </ListBox.Item>        </ListBox>      </ComboBox.Popover>    </ComboBox>  );}

Asynchronous Loading

import { Collection, ComboBox, EmptyState, Input, Label, ListBox, ListBoxLoadMoreItem, Spinner } from '@lenso/ui';import { useAsyncList } from '@react-stately/data';
interface Character {  name: string;}
function AsyncComboBox() {  const list = useAsyncList<Character>({    async load({cursor, filterText, signal}) {      const res = await fetch(        cursor || `https://swapi.py4e.com/api/people/?search=${filterText}`,        { signal }      );      const json = await res.json();
      return {        items: json.results,        cursor: json.next,      };    },  });
  return (    <ComboBox      allowsEmptyCollection      className="w-[256px]"      inputValue={list.filterText}      onInputChange={list.setFilterText}    >      <Label>Pick a Character</Label>      <ComboBox.InputGroup>        <Input placeholder="Star Wars characters..." />        <ComboBox.Trigger />      </ComboBox.InputGroup>      <ComboBox.Popover>        <ListBox renderEmptyState={() => <EmptyState />}>          <Collection items={list.items}>            {(item) => (              <ListBox.Item id={item.name} textValue={item.name}>                {item.name}                <ListBox.ItemIndicator />              </ListBox.Item>            )}          </Collection>          <ListBoxLoadMoreItem            isLoading={list.loadingState === "loadingMore"}            onLoadMore={list.loadMore}          >            <div className="flex items-center justify-center gap-2 py-2">              <Spinner size="sm" />              <span className="text-sm text-muted">Loading more...</span>            </div>          </ListBoxLoadMoreItem>        </ListBox>      </ComboBox.Popover>    </ComboBox>  );}

Custom Filtering

import { ComboBox, Input, Label, ListBox } from '@lenso/ui';
<ComboBox  className="w-[256px]"  defaultFilter={(text, inputValue) => {    if (!inputValue) return true;    return text.toLowerCase().includes(inputValue.toLowerCase());  }}>  <Label>Animal</Label>  <ComboBox.InputGroup>    <Input placeholder="Search animals..." />    <ComboBox.Trigger />  </ComboBox.InputGroup>  <ComboBox.Popover>    <ListBox>      <ListBox.Item id="cat" textValue="Cat">        Cat        <ListBox.ItemIndicator />      </ListBox.Item>      <ListBox.Item id="dog" textValue="Dog">        Dog        <ListBox.ItemIndicator />      </ListBox.Item>    </ListBox>  </ComboBox.Popover></ComboBox>

Control when the popover opens using the menuTrigger prop:

import { ComboBox, Description, Input, Label, ListBox } from '@lenso/ui';
// Opens on focus (default)<ComboBox className="w-[256px]" menuTrigger="focus">  <Label>Favorite Animal</Label>  <ComboBox.InputGroup>    <Input placeholder="Search animals..." />    <ComboBox.Trigger />  </ComboBox.InputGroup>  <ComboBox.Popover>    <ListBox>      <ListBox.Item id="cat" textValue="Cat">        Cat        <ListBox.ItemIndicator />      </ListBox.Item>    </ListBox>  </ComboBox.Popover>  <Description>Popover opens when the input is focused</Description></ComboBox>
// Opens when typing<ComboBox className="w-[256px]" menuTrigger="input">  <Label>Favorite Animal</Label>  <ComboBox.InputGroup>    <Input placeholder="Search animals..." />    <ComboBox.Trigger />  </ComboBox.InputGroup>  <ComboBox.Popover>    <ListBox>      <ListBox.Item id="cat" textValue="Cat">        Cat        <ListBox.ItemIndicator />      </ListBox.Item>    </ListBox>  </ComboBox.Popover>  <Description>Popover opens when the user edits the input text</Description></ComboBox>
// Opens only manually<ComboBox className="w-[256px]" menuTrigger="manual">  <Label>Favorite Animal</Label>  <ComboBox.InputGroup>    <Input placeholder="Search animals..." />    <ComboBox.Trigger />  </ComboBox.InputGroup>  <ComboBox.Popover>    <ListBox>      <ListBox.Item id="cat" textValue="Cat">        Cat        <ListBox.ItemIndicator />      </ListBox.Item>    </ListBox>  </ComboBox.Popover>  <Description>Popover only opens when the trigger button is pressed or arrow keys are used</Description></ComboBox>

Form Value

Use the formValue prop to control whether the selected item's key or text is submitted in forms:

import { Button, ComboBox, FieldError, Form, Input, Label, ListBox } from '@lenso/ui';
function FormValueExample() {  const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {    e.preventDefault();    const formData = new FormData(e.currentTarget);    console.log('Submitted value:', formData.get('animal')); // Will be "cat" (the key)  };
  return (    <Form onSubmit={onSubmit}>      {/* Submits the key (default) */}      <ComboBox name="animal" formValue="key" isRequired>        <Label>Animal</Label>        <ComboBox.InputGroup>          <Input placeholder="Select an animal..." />          <ComboBox.Trigger />        </ComboBox.InputGroup>        <ComboBox.Popover>          <ListBox>            <ListBox.Item id="cat" textValue="Cat">              Cat              <ListBox.ItemIndicator />            </ListBox.Item>            <ListBox.Item id="dog" textValue="Dog">              Dog              <ListBox.ItemIndicator />            </ListBox.Item>          </ListBox>        </ComboBox.Popover>        <FieldError />      </ComboBox>
      {/* Submits the text */}      <ComboBox name="animal-text" formValue="text" isRequired>        <Label>Animal (text)</Label>        <ComboBox.InputGroup>          <Input placeholder="Select an animal..." />          <ComboBox.Trigger />        </ComboBox.InputGroup>        <ComboBox.Popover>          <ListBox>            <ListBox.Item id="cat" textValue="Cat">              Cat              <ListBox.ItemIndicator />            </ListBox.Item>            <ListBox.Item id="dog" textValue="Dog">              Dog              <ListBox.ItemIndicator />            </ListBox.Item>          </ListBox>        </ComboBox.Popover>        <FieldError />      </ComboBox>
      <Button type="submit">Submit</Button>    </Form>  );}

Validation Behavior

Control how validation is displayed using the validationBehavior prop:

import { Button, ComboBox, FieldError, Form, Input, Label, ListBox } from '@lenso/ui';
function ValidationExample() {  return (    <div className="space-y-8">      {/* Native validation (default) - blocks form submission */}      <Form>        <ComboBox name="animal" isRequired validationBehavior="native">          <Label>Animal (native validation)</Label>          <ComboBox.InputGroup>            <Input placeholder="Select an animal..." />            <ComboBox.Trigger />          </ComboBox.InputGroup>          <ComboBox.Popover>            <ListBox>              <ListBox.Item id="cat" textValue="Cat">                Cat                <ListBox.ItemIndicator />              </ListBox.Item>            </ListBox>          </ComboBox.Popover>          <FieldError />        </ComboBox>        <Button type="submit">Submit</Button>      </Form>
      {/* ARIA validation - shows errors in realtime, doesn't block submission */}      <Form>        <ComboBox name="animal-aria" isRequired validationBehavior="aria">          <Label>Animal (ARIA validation)</Label>          <ComboBox.InputGroup>            <Input placeholder="Select an animal..." />            <ComboBox.Trigger />          </ComboBox.InputGroup>          <ComboBox.Popover>            <ListBox>              <ListBox.Item id="cat" textValue="Cat">                Cat                <ListBox.ItemIndicator />              </ListBox.Item>            </ListBox>          </ComboBox.Popover>          <FieldError />        </ComboBox>        <Button type="submit">Submit</Button>      </Form>    </div>  );}

Custom Validation

Use the validate prop to add custom validation logic:

import { ComboBox, FieldError, Input, Label, ListBox } from '@lenso/ui';
function CustomValidationExample() {  return (    <ComboBox      className="w-[256px]"      isRequired      validate={(value) => {        if (!value || value.selectedKey === null) {          return 'Please select an animal';        }        if (value.selectedKey === 'snake') {          return 'Snakes are not allowed';        }        return true;      }}    >      <Label>Favorite Animal</Label>      <ComboBox.InputGroup>        <Input placeholder="Search animals..." />        <ComboBox.Trigger />      </ComboBox.InputGroup>      <ComboBox.Popover>        <ListBox>          <ListBox.Item id="cat" textValue="Cat">            Cat            <ListBox.ItemIndicator />          </ListBox.Item>          <ListBox.Item id="dog" textValue="Dog">            Dog            <ListBox.ItemIndicator />          </ListBox.Item>          <ListBox.Item id="snake" textValue="Snake">            Snake            <ListBox.ItemIndicator />          </ListBox.Item>        </ListBox>      </ComboBox.Popover>      <FieldError />    </ComboBox>  );}

Read Only

Use the isReadOnly prop to make the comboBox read-only:

import { ComboBox, Input, Label, ListBox } from '@lenso/ui';
<ComboBox className="w-[256px]" isReadOnly defaultSelectedKey="cat">  <Label>Favorite Animal</Label>  <ComboBox.InputGroup>    <Input placeholder="Search animals..." />    <ComboBox.Trigger />  </ComboBox.InputGroup>  <ComboBox.Popover>    <ListBox>      <ListBox.Item id="cat" textValue="Cat">        Cat        <ListBox.ItemIndicator />      </ListBox.Item>      <ListBox.Item id="dog" textValue="Dog">        Dog        <ListBox.ItemIndicator />      </ListBox.Item>    </ListBox>  </ComboBox.Popover></ComboBox>

Accessibility

The ComboBox component implements the ARIA comboBox pattern and provides:

  • Full keyboard navigation support
  • Screen reader announcements for selection changes and input changes
  • Proper focus management
  • Support for disabled states
  • Typeahead search functionality
  • HTML form integration
  • Support for custom values

For more information, see the React Aria ComboBox documentation.