Combobox

An input that filters a list of options as you type.

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"

const frameworks = ["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"] as const

export default function ComboboxDemo() {
  return (
    <Combobox items={frameworks}>
      <ComboboxInput placeholder="Select a framework" aria-label="Framework" />
      <ComboboxContent>
        <ComboboxEmpty>No items found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx lucide-react

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts and state-styles.ts from the same page. states.ts holds the popup's surface, the items' nested corner, the chips frame's field surface and focus ring, and the dimmed item. The list, the items and the chips read their Base UI state through state-styles.ts. Include interop.css for icon sizing, button trigger backgrounds and hiding the chevron beside the clear button.

Paste field-state.ts from the same installation page.

Add Input, Input Group and Button. The input renders inside an InputGroup frame, and the trigger, clear and chip-remove controls are buttons.

Copy and paste the following code into your project.

components/ui/combobox.tsx
"use client"

import type { CSSProperties } from "react"
import { createContext, use, useRef } from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"
import { Check, ChevronDown, X } from "lucide-react"

import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupButton } from "@/components/ui/input-group"
import { stateStyles } from "@/lib/state-styles"
import { states } from "@/theme/states"
import {
  color,
  duration,
  ease,
  layer,
  leading,
  radius,
  shadow,
  space,
  text,
} from "@/theme/tokens.stylex"

const ComboboxContentContext = createContext(false)

const styles = stylex.create({
  hidden: { opacity: 0 },
  content: {
    zIndex: layer.popup,
    width: "var(--anchor-width)",
    minWidth: "9rem",
    maxWidth: "var(--available-width)",
    maxHeight: "var(--available-height)",
    overflow: "hidden",
    "--carbon-popup-radius": radius.lg,
    borderRadius: "var(--carbon-popup-radius)",
    transformOrigin: "var(--transform-origin)",
    transitionProperty: "opacity, transform",
    transitionDuration: { default: duration.fade, "@media (prefers-reduced-motion: reduce)": "0s" },
    transitionTimingFunction: ease.standard,
  },
  list: {
    maxHeight: "min(calc(18rem - 2.25rem), calc(var(--available-height) - 2.25rem))",
    overflowY: "auto",
    overscrollBehavior: "contain",
    scrollPaddingTop: space.s1,
    scrollPaddingBottom: space.s1,
    padding: space.s1,
  },
  listEmpty: { padding: 0 },
  item: {
    position: "relative",
    display: "flex",
    width: "100%",
    alignItems: "center",
    gap: space.s1_5,
    cursor: "default",
    userSelect: "none",
    paddingBlock: space.s1,
    paddingInlineStart: space.s1_5,
    paddingInlineEnd: space.s8,
    fontSize: text.sm,
    lineHeight: leading.sm,
    outlineStyle: "none",
    "--carbon-icon-size": space.s4,
  },
  indicator: {
    pointerEvents: "none",
    position: "absolute",
    insetInlineEnd: space.s2,
    display: "flex",
    width: space.s4,
    height: space.s4,
    alignItems: "center",
    justifyContent: "center",
  },
  label: {
    paddingInline: space.s1_5,
    paddingBlock: space.s1,
    fontSize: text.xs,
    lineHeight: leading.xs,
    fontWeight: 500,
    color: color.mutedForeground,
  },
  empty: {
    display: "flex",
    width: "100%",
    justifyContent: "center",
    paddingBlock: { default: space.s2, ":empty": 0 },
    textAlign: "center",
    fontSize: text.sm,
    lineHeight: leading.sm,
    color: color.mutedForeground,
  },
  separator: {
    marginInline: `calc(-1 * ${space.s1})`,
    marginBlock: space.s1,
    height: 1,
    backgroundColor: color.border,
  },
  chevron: { color: color.mutedForeground },
  frame: { width: "auto" },
  frameInPopup: {
    margin: space.s1,
    marginBlockEnd: 0,
    borderRadius: `max(0px, calc(var(--carbon-popup-radius, ${radius.lg}) - ${space.s1}))`,
    backgroundColor: `color-mix(in oklab, ${color.input} 30%, transparent)`,
  },
  inputTrigger: {
    color: color.mutedForeground,
    "--carbon-menu-open-bg": color.transparent,
    "--carbon-menu-open-fg": color.current,
  },
  chips: {
    display: "flex",
    minHeight: space.s8,
    flexWrap: "wrap",
    alignItems: "center",
    gap: space.s1,
    borderRadius: radius.lg,
    borderWidth: 1,
    borderStyle: "solid",
    paddingInline: space.s1,
    paddingBlock: space.s1,
    fontSize: text.sm,
    lineHeight: leading.sm,
    transitionProperty: "color, background-color, border-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
  },
  chipsDrop: {
    borderColor: {
      default: "var(--carbon-border-color)",
      ":hover": color.fieldHoverEdge,
      ":focus-within": color.fieldFocusEdge,
    },
    boxShadow: { default: shadow.field, ":focus-within": `${shadow.field}, ${shadow.fieldFocus}` },
  },
  chip: {
    display: "flex",
    width: "fit-content",
    height: space.s5,
    alignItems: "center",
    justifyContent: "center",
    gap: space.s1,
    whiteSpace: "nowrap",
    borderRadius: radius.sm,
    paddingInline: space.s1_5,
    backgroundColor: color.muted,
    color: color.foreground,
    fontSize: text.xs,
    lineHeight: leading.xs,
    fontWeight: 500,
  },
  chipWithRemove: { paddingInlineEnd: 0 },
  chipRemove: {
    width: space.s5,
    height: space.s5,
    marginInlineStart: `calc(-1 * ${space.s1})`,
    opacity: { default: 0.5, ":hover": 1 },
  },
  chipsInput: {
    flexGrow: 1,
    minWidth: space.s16,
    height: space.s5,
    paddingInline: space.s1_5,
    borderWidth: 0,
    backgroundColor: color.transparent,
    color: color.foreground,
    fontSize: text.sm,
    lineHeight: leading.sm,
    outlineStyle: "none",
    "::placeholder": { color: color.mutedForeground },
  },
})

const comboboxStyles = {
  content: styles.content,
  list: styles.list,
  item: [styles.item, states.popupItem],
  itemHighlighted: states.itemHighlight,
  itemDisabled: states.disabled,
  label: styles.label,
  empty: styles.empty,
  separator: styles.separator,
  chips: [styles.chips, states.focusWithinRing, states.fieldSurface, styles.chipsDrop],
  chip: styles.chip,
  chipsInput: styles.chipsInput,
}

const slide = stylex.create({
  top: { transform: `scale(0.95) translateY(${space.s2})` },
  bottom: { transform: `scale(0.95) translateY(calc(-1 * ${space.s2}))` },
  left: { transform: `scale(0.95) translateX(${space.s2})` },
  right: { transform: `scale(0.95) translateX(calc(-1 * ${space.s2}))` },
  "inline-start": { transform: `scale(0.95) translateX(${space.s2})` },
  "inline-end": { transform: `scale(0.95) translateX(calc(-1 * ${space.s2}))` },
})

type Overrides = { xstyle?: StyleXStyles; className?: string }

type StateOverrides = Overrides & { style?: CSSProperties }

function Combobox<Value, Multiple extends boolean | undefined = false>(
  props: ComboboxPrimitive.Root.Props<Value, Multiple>,
) {
  return <ComboboxPrimitive.Root {...props} />
}

function ComboboxValue(props: ComboboxPrimitive.Value.Props) {
  return <ComboboxPrimitive.Value {...props} />
}

type ComboboxTriggerProps = Omit<ComboboxPrimitive.Trigger.Props, "className"> & Overrides

function ComboboxTrigger({ xstyle, className, children, ...props }: ComboboxTriggerProps) {
  const sx = stylex.props(xstyle)
  return (
    <ComboboxPrimitive.Trigger
      data-slot="combobox-trigger"
      {...sx}
      {...props}
      className={clsx("carbon-icons", sx.className, className)}
    >
      {children}
      <ChevronDown {...stylex.props(styles.chevron)} />
    </ComboboxPrimitive.Trigger>
  )
}

type ComboboxClearProps = Omit<ComboboxPrimitive.Clear.Props, "className"> & Overrides

function ComboboxClear({ xstyle, className, ...props }: ComboboxClearProps) {
  return (
    <ComboboxPrimitive.Clear
      data-slot="combobox-clear"
      render={
        <InputGroupButton
          data-slot="combobox-clear"
          size="icon-xs"
          xstyle={xstyle}
          className={className}
        />
      }
      aria-label="Clear"
      {...props}
    >
      <X />
    </ComboboxPrimitive.Clear>
  )
}

type ComboboxInputProps = Omit<ComboboxPrimitive.Input.Props, "className"> &
  Overrides & {
    showTrigger?: boolean
    showClear?: boolean
  }

function ComboboxInput({
  xstyle,
  className,
  children,
  disabled = false,
  "aria-invalid": ariaInvalid,
  showTrigger = true,
  showClear = false,
  ...props
}: ComboboxInputProps) {
  const inPopup = use(ComboboxContentContext)
  const invalid = ariaInvalid != null && ariaInvalid !== false

  return (
    <ComboboxPrimitive.InputGroup
      render={
        <InputGroup
          disabled={disabled}
          invalid={invalid}
          xstyle={[styles.frame, inPopup && styles.frameInPopup, xstyle]}
          className={className}
        />
      }
    >
      <ComboboxPrimitive.Input render={<Input />} aria-invalid={invalid || undefined} {...props} />
      {(showTrigger || showClear) && (
        <InputGroupAddon align="inline-end">
          {showClear && <ComboboxClear disabled={disabled} />}
          {showTrigger && (
            <ComboboxTrigger
              render={
                <InputGroupButton
                  data-slot="combobox-trigger"
                  size="icon-xs"
                  xstyle={styles.inputTrigger}
                />
              }
              aria-label="Open"
              disabled={disabled}
            />
          )}
        </InputGroupAddon>
      )}
      {children}
    </ComboboxPrimitive.InputGroup>
  )
}

type ComboboxContentProps = Omit<ComboboxPrimitive.Popup.Props, "className" | "style"> &
  Pick<
    ComboboxPrimitive.Positioner.Props,
    "align" | "alignOffset" | "side" | "sideOffset" | "anchor"
  > &
  StateOverrides

function ComboboxContent({
  xstyle,
  style,
  className,
  side = "bottom",
  sideOffset = 6,
  align = "start",
  alignOffset = 0,
  anchor,
  ...props
}: ComboboxContentProps) {
  return (
    <ComboboxPrimitive.Portal>
      <ComboboxPrimitive.Positioner
        align={align}
        alignOffset={alignOffset}
        side={side}
        sideOffset={sideOffset}
        anchor={anchor}
        {...stylex.props(states.positioner)}
      >
        <ComboboxContentContext value>
          <ComboboxPrimitive.Popup
            data-slot="combobox-content"
            {...props}
            {...stateStyles(
              (state) => [
                states.popupSurface,
                styles.content,
                (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                  styles.hidden,
                (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                  slide[state.side],
                xstyle,
              ],
              { className, style },
            )}
          />
        </ComboboxContentContext>
      </ComboboxPrimitive.Positioner>
    </ComboboxPrimitive.Portal>
  )
}

type ComboboxListProps = Omit<ComboboxPrimitive.List.Props, "className" | "style"> & StateOverrides

function ComboboxList({ xstyle, style, className, ...props }: ComboboxListProps) {
  return (
    <ComboboxPrimitive.List
      data-slot="combobox-list"
      {...props}
      {...stateStyles((state) => [styles.list, state.empty && styles.listEmpty, xstyle], {
        className,
        style,
        hook: "no-scrollbar",
      })}
    />
  )
}

type ComboboxItemProps = Omit<ComboboxPrimitive.Item.Props, "className" | "style"> & StateOverrides

function ComboboxItem({ xstyle, style, className, children, ...props }: ComboboxItemProps) {
  return (
    <ComboboxPrimitive.Item
      data-slot="combobox-item"
      {...props}
      {...stateStyles(
        (state) => [
          styles.item,
          states.popupItem,
          state.highlighted && states.itemHighlight,
          state.disabled && states.disabled,
          xstyle,
        ],
        { className, style, hook: "carbon-icons" },
      )}
    >
      {children}
      <ComboboxPrimitive.ItemIndicator
        render={<span data-slot="combobox-item-indicator" {...stylex.props(styles.indicator)} />}
      >
        <Check />
      </ComboboxPrimitive.ItemIndicator>
    </ComboboxPrimitive.Item>
  )
}

function ComboboxGroup(props: ComboboxPrimitive.Group.Props) {
  return <ComboboxPrimitive.Group data-slot="combobox-group" {...props} />
}

type ComboboxLabelProps = Omit<ComboboxPrimitive.GroupLabel.Props, "className"> & Overrides

function ComboboxLabel({ xstyle, className, ...props }: ComboboxLabelProps) {
  const sx = stylex.props(styles.label, xstyle)
  return (
    <ComboboxPrimitive.GroupLabel
      data-slot="combobox-label"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

function ComboboxCollection(props: ComboboxPrimitive.Collection.Props) {
  return <ComboboxPrimitive.Collection {...props} />
}

type ComboboxEmptyProps = Omit<ComboboxPrimitive.Empty.Props, "className"> & Overrides

function ComboboxEmpty({ xstyle, className, ...props }: ComboboxEmptyProps) {
  const sx = stylex.props(styles.empty, xstyle)
  return (
    <ComboboxPrimitive.Empty
      data-slot="combobox-empty"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type ComboboxSeparatorProps = Omit<ComboboxPrimitive.Separator.Props, "className"> & Overrides

function ComboboxSeparator({ xstyle, className, ...props }: ComboboxSeparatorProps) {
  const sx = stylex.props(styles.separator, xstyle)
  return (
    <ComboboxPrimitive.Separator
      data-slot="combobox-separator"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type ComboboxChipsProps = Omit<ComboboxPrimitive.Chips.Props, "className"> & Overrides

function ComboboxChips({ xstyle, className, ...props }: ComboboxChipsProps) {
  const sx = stylex.props(
    styles.chips,
    states.focusWithinRing,
    states.fieldSurface,
    styles.chipsDrop,
    xstyle,
  )
  return (
    <ComboboxPrimitive.Chips
      data-slot="combobox-chips"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type ComboboxChipProps = Omit<ComboboxPrimitive.Chip.Props, "className" | "style"> &
  StateOverrides & { showRemove?: boolean }

function ComboboxChip({
  xstyle,
  style,
  className,
  children,
  showRemove = true,
  ...props
}: ComboboxChipProps) {
  return (
    <ComboboxPrimitive.Chip
      data-slot="combobox-chip"
      {...props}
      {...stateStyles(
        (state) => [
          styles.chip,
          showRemove && styles.chipWithRemove,
          state.disabled && states.disabled,
          xstyle,
        ],
        { className, style },
      )}
    >
      {children}
      {showRemove && (
        <ComboboxPrimitive.ChipRemove
          data-slot="combobox-chip-remove"
          render={<Button variant="ghost" size="icon-xs" xstyle={styles.chipRemove} />}
          aria-label="Remove"
        >
          <X />
        </ComboboxPrimitive.ChipRemove>
      )}
    </ComboboxPrimitive.Chip>
  )
}

type ComboboxChipsInputProps = Omit<ComboboxPrimitive.Input.Props, "className"> & Overrides

function ComboboxChipsInput({ xstyle, className, ...props }: ComboboxChipsInputProps) {
  const sx = stylex.props(styles.chipsInput, xstyle)
  return (
    <ComboboxPrimitive.Input
      data-slot="combobox-chip-input"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

function useComboboxAnchor() {
  return useRef<HTMLDivElement | null>(null)
}

export {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxClear,
  ComboboxCollection,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxLabel,
  ComboboxList,
  ComboboxSeparator,
  ComboboxTrigger,
  ComboboxValue,
  comboboxStyles,
  useComboboxAnchor,
}

Update the import paths to match your project setup.

Usage

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"
const frameworks = ["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"]

export function Example() {
  return (
    <Combobox items={frameworks}>
      <ComboboxInput placeholder="Select a framework" aria-label="Framework" />
      <ComboboxContent>
        <ComboboxEmpty>No items found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

Pass the options through items and render each one from the function child of ComboboxList. Base UI filters items against what is typed, so a list written as static children does not filter and ComboboxEmpty never shows.

Give the input an aria-label or a Label. There is no Field to name it.

Composition

Simple

Combobox
├── ComboboxInput
└── ComboboxContent
    ├── ComboboxEmpty
    └── ComboboxList
        └── ComboboxItem

With chips

Combobox
├── ComboboxChips
│   ├── ComboboxValue
│   │   └── ComboboxChip
│   └── ComboboxChipsInput
└── ComboboxContent
    ├── ComboboxEmpty
    └── ComboboxList
        └── ComboboxItem

With groups

Combobox
├── ComboboxInput
└── ComboboxContent
    ├── ComboboxEmpty
    └── ComboboxList
        └── ComboboxGroup
            ├── ComboboxSeparator
            ├── ComboboxLabel
            └── ComboboxCollection
                └── ComboboxItem

Custom items

Pass itemToStringLabel when the items are objects. The filter matches against its result, and the input shows it once an item is picked. Items shaped { value, label } need neither.

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

const frameworks: Framework[] = [
  { label: "Next.js", value: "next" },
  { label: "SvelteKit", value: "sveltekit" },
  { label: "Nuxt", value: "nuxt" },
]

export function Example() {
  return (
    <Combobox items={frameworks} itemToStringLabel={(framework: Framework) => framework.label}>
      <ComboboxInput placeholder="Select a framework" aria-label="Framework" />
      <ComboboxContent>
        <ComboboxEmpty>No items found.</ComboboxEmpty>
        <ComboboxList>
          {(framework: Framework) => (
            <ComboboxItem key={framework.value} value={framework}>
              {framework.label}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

Basic

A single input over a flat list.

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"

const frameworks = ["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"] as const

export default function ComboboxBasic() {
  return (
    <Combobox items={frameworks}>
      <ComboboxInput placeholder="Select a framework" aria-label="Framework" />
      <ComboboxContent>
        <ComboboxEmpty>No items found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

Multiple

Set multiple and swap the input for ComboboxChips. The chosen values render as ComboboxChips from the function child of ComboboxValue, and ComboboxChipsInput filters what is left.

import { Fragment } from "react"
import * as stylex from "@stylexjs/stylex"
import {
  Combobox,
  ComboboxChip,
  ComboboxChips,
  ComboboxChipsInput,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxItem,
  ComboboxList,
  ComboboxValue,
  useComboboxAnchor,
} from "@/components/ui/combobox"

const frameworks = ["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"] as const

const styles = stylex.create({
  chips: { width: "100%", maxWidth: "20rem" },
})

export default function ComboboxMultiple() {
  const anchor = useComboboxAnchor()

  return (
    <Combobox multiple autoHighlight items={frameworks} defaultValue={[frameworks[0]]}>
      <ComboboxChips ref={anchor} xstyle={styles.chips}>
        <ComboboxValue>
          {(values: string[]) => (
            <Fragment>
              {values.map((value) => (
                <ComboboxChip key={value}>{value}</ComboboxChip>
              ))}
              <ComboboxChipsInput placeholder="Add framework" aria-label="Frameworks" />
            </Fragment>
          )}
        </ComboboxValue>
      </ComboboxChips>
      <ComboboxContent anchor={anchor}>
        <ComboboxEmpty>No items found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

Give ComboboxChips the ref from useComboboxAnchor and pass the same ref to ComboboxContent as anchor. Base UI otherwise positions against the input, which is only part of the frame.

Clear button

Set showClear on the input. The X is mounted only while there is a value, and it replaces the chevron while it is shown.

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"

const frameworks = ["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"] as const

export default function ComboboxClear() {
  return (
    <Combobox items={frameworks} defaultValue={frameworks[0]}>
      <ComboboxInput placeholder="Select a framework" aria-label="Framework" showClear />
      <ComboboxContent>
        <ComboboxEmpty>No items found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

Groups

Pass groups shaped { value, items } to items. Render each one as a ComboboxGroup with the group's items, a ComboboxLabel and a ComboboxCollection that renders the items.

import {
  Combobox,
  ComboboxCollection,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxLabel,
  ComboboxList,
  ComboboxSeparator,
} from "@/components/ui/combobox"

const timezones = [
  {
    value: "Americas",
    items: [
      "(GMT-5) New York",
      "(GMT-8) Los Angeles",
      "(GMT-6) Chicago",
      "(GMT-5) Toronto",
      "(GMT-8) Vancouver",
      "(GMT-3) São Paulo",
    ],
  },
  {
    value: "Europe",
    items: [
      "(GMT+0) London",
      "(GMT+1) Paris",
      "(GMT+1) Berlin",
      "(GMT+1) Rome",
      "(GMT+1) Madrid",
      "(GMT+1) Amsterdam",
    ],
  },
  {
    value: "Asia/Pacific",
    items: [
      "(GMT+9) Tokyo",
      "(GMT+8) Shanghai",
      "(GMT+8) Singapore",
      "(GMT+4) Dubai",
      "(GMT+11) Sydney",
      "(GMT+9) Seoul",
    ],
  },
]

type Timezones = (typeof timezones)[number]

export default function ComboboxGroups() {
  return (
    <Combobox items={timezones}>
      <ComboboxInput placeholder="Select a timezone" aria-label="Timezone" />
      <ComboboxContent>
        <ComboboxEmpty>No timezones found.</ComboboxEmpty>
        <ComboboxList>
          {(group: Timezones, index: number) => (
            <ComboboxGroup key={group.value} items={group.items}>
              {index > 0 && <ComboboxSeparator />}
              <ComboboxLabel>{group.value}</ComboboxLabel>
              <ComboboxCollection>
                {(item: string) => (
                  <ComboboxItem key={item} value={item}>
                    {item}
                  </ComboboxItem>
                )}
              </ComboboxCollection>
            </ComboboxGroup>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

Filtering drops a group that has no match left, so a ComboboxSeparator goes inside the group rather than between groups. Render it before every group but the first.

Custom items

Render anything inside ComboboxItem. The tick stays at the trailing edge.

import * as stylex from "@stylexjs/stylex"
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"
import { VStack } from "@/components/ui/stack"
import { color, leading, text } from "@/theme/tokens.stylex"

type Country = { code: string; label: string; continent: string }

const countries: Country[] = [
  { code: "ar", label: "Argentina", continent: "South America" },
  { code: "au", label: "Australia", continent: "Oceania" },
  { code: "br", label: "Brazil", continent: "South America" },
  { code: "ca", label: "Canada", continent: "North America" },
  { code: "cn", label: "China", continent: "Asia" },
  { code: "eg", label: "Egypt", continent: "Africa" },
  { code: "fr", label: "France", continent: "Europe" },
  { code: "de", label: "Germany", continent: "Europe" },
  { code: "jp", label: "Japan", continent: "Asia" },
  { code: "ke", label: "Kenya", continent: "Africa" },
  { code: "mx", label: "Mexico", continent: "North America" },
  { code: "nz", label: "New Zealand", continent: "Oceania" },
  { code: "za", label: "South Africa", continent: "Africa" },
  { code: "gb", label: "United Kingdom", continent: "Europe" },
  { code: "us", label: "United States", continent: "North America" },
]

const styles = stylex.create({
  title: { whiteSpace: "nowrap", fontWeight: 500 },
  description: { color: color.mutedForeground, fontSize: text.xs, lineHeight: leading.xs },
})

export default function ComboboxCustom() {
  return (
    <Combobox items={countries} itemToStringLabel={(country: Country) => country.label}>
      <ComboboxInput placeholder="Search countries..." aria-label="Country" />
      <ComboboxContent>
        <ComboboxEmpty>No countries found.</ComboboxEmpty>
        <ComboboxList>
          {(country: Country) => (
            <ComboboxItem key={country.code} value={country}>
              <VStack gap={0.5}>
                <span {...stylex.props(styles.title)}>{country.label}</span>
                <span {...stylex.props(styles.description)}>
                  {country.continent} ({country.code})
                </span>
              </VStack>
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

Invalid

Set invalid on Field to style its control, or aria-invalid on a bare control. The examples show both.

import { VStack } from "@/components/ui/stack"
import { Field, FieldLabel } from "@/components/ui/field"
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"

const frameworks = ["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"] as const

function BareControl({ bare = false }: { bare?: boolean }) {
  return (
    <Combobox items={frameworks}>
      <ComboboxInput
        placeholder="Select a framework"
        aria-label="Framework"
        aria-invalid={bare || undefined}
      />
      <ComboboxContent>
        <ComboboxEmpty>No items found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

export default function InvalidExample() {
  return (
    <VStack gap={5}>
      <BareControl bare />
      <Field invalid>
        <FieldLabel>Framework</FieldLabel>
        <BareControl />
      </Field>
    </VStack>
  )
}

Disabled

Set disabled on Field to disable its control and dim its label, or on a bare control directly.

import { Field, FieldLabel } from "@/components/ui/field"
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"

const frameworks = ["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"] as const

export default function ComboboxDisabled() {
  return (
    <Field disabled>
      <FieldLabel>Framework</FieldLabel>
      <Combobox items={frameworks}>
        <ComboboxInput placeholder="Select a framework" />
        <ComboboxContent>
          <ComboboxEmpty>No items found.</ComboboxEmpty>
          <ComboboxList>
            {(item: string) => (
              <ComboboxItem key={item} value={item}>
                {item}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </Combobox>
    </Field>
  )
}

Auto highlight

Set autoHighlight on Combobox to highlight the first match as you type, so Enter picks it.

import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
} from "@/components/ui/combobox"

const frameworks = ["Next.js", "SvelteKit", "Nuxt.js", "Remix", "Astro"] as const

export default function ComboboxAutoHighlight() {
  return (
    <Combobox items={frameworks} autoHighlight>
      <ComboboxInput placeholder="Select a framework" aria-label="Framework" />
      <ComboboxContent>
        <ComboboxEmpty>No items found.</ComboboxEmpty>
        <ComboboxList>
          {(item: string) => (
            <ComboboxItem key={item} value={item}>
              {item}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

Render a ComboboxTrigger as a Button and move ComboboxInput inside ComboboxContent, with showTrigger={false}. ComboboxValue shows the current value in the button.

import * as stylex from "@stylexjs/stylex"
import { Button } from "@/components/ui/button"
import {
  Combobox,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxInput,
  ComboboxItem,
  ComboboxList,
  ComboboxTrigger,
  ComboboxValue,
} from "@/components/ui/combobox"

type Country = { code: string; label: string }

const countries: Country[] = [
  { code: "", label: "Select country" },
  { code: "ar", label: "Argentina" },
  { code: "au", label: "Australia" },
  { code: "br", label: "Brazil" },
  { code: "ca", label: "Canada" },
  { code: "cn", label: "China" },
  { code: "eg", label: "Egypt" },
  { code: "fr", label: "France" },
  { code: "de", label: "Germany" },
  { code: "jp", label: "Japan" },
  { code: "ke", label: "Kenya" },
  { code: "mx", label: "Mexico" },
  { code: "nz", label: "New Zealand" },
  { code: "za", label: "South Africa" },
  { code: "gb", label: "United Kingdom" },
  { code: "us", label: "United States" },
]

const styles = stylex.create({
  trigger: { width: "16rem", justifyContent: "space-between", fontWeight: 400 },
})

export default function ComboboxPopup() {
  return (
    <Combobox items={countries} defaultValue={countries[0]}>
      <ComboboxTrigger render={<Button variant="outline" xstyle={styles.trigger} />}>
        <ComboboxValue />
      </ComboboxTrigger>
      <ComboboxContent>
        <ComboboxInput showTrigger={false} placeholder="Search" aria-label="Search countries" />
        <ComboboxEmpty>No items found.</ComboboxEmpty>
        <ComboboxList>
          {(item: Country) => (
            <ComboboxItem key={item.code} value={item}>
              {item.label}
            </ComboboxItem>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

Input group

ComboboxInput is an InputGroup, so an InputGroupAddon passed as its child renders inside the frame.

import {
  Combobox,
  ComboboxCollection,
  ComboboxContent,
  ComboboxEmpty,
  ComboboxGroup,
  ComboboxInput,
  ComboboxItem,
  ComboboxLabel,
  ComboboxList,
} from "@/components/ui/combobox"
import { InputGroupAddon } from "@/components/ui/input-group"
import { Globe } from "lucide-react"

const timezones = [
  {
    value: "Americas",
    items: ["(GMT-5) New York", "(GMT-8) Los Angeles", "(GMT-6) Chicago", "(GMT-5) Toronto"],
  },
  {
    value: "Europe",
    items: ["(GMT+0) London", "(GMT+1) Paris", "(GMT+1) Berlin", "(GMT+1) Rome"],
  },
  {
    value: "Asia/Pacific",
    items: ["(GMT+9) Tokyo", "(GMT+8) Shanghai", "(GMT+8) Singapore", "(GMT+11) Sydney"],
  },
]

type Timezones = (typeof timezones)[number]

export default function ComboboxInputGroup() {
  return (
    <Combobox items={timezones}>
      <ComboboxInput placeholder="Select a timezone" aria-label="Timezone">
        <InputGroupAddon>
          <Globe />
        </InputGroupAddon>
      </ComboboxInput>
      <ComboboxContent>
        <ComboboxEmpty>No timezones found.</ComboboxEmpty>
        <ComboboxList>
          {(group: Timezones) => (
            <ComboboxGroup key={group.value} items={group.items}>
              <ComboboxLabel>{group.value}</ComboboxLabel>
              <ComboboxCollection>
                {(item: string) => (
                  <ComboboxItem key={item} value={item}>
                    {item}
                  </ComboboxItem>
                )}
              </ComboboxCollection>
            </ComboboxGroup>
          )}
        </ComboboxList>
      </ComboboxContent>
    </Combobox>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI Combobox part of the same name and accepts its props. See the Base UI reference for the full list. Each styled part also takes xstyle and className. ComboboxList, ComboboxItem and ComboboxChip take style too. It is real inline CSS, merged over whatever their Base UI state resolves to.

Combobox

PropTypeDefault
itemsreadonly Item[] | readonly Group[]—
multiplebooleanfalse
valueItem | Item[] | null—
defaultValueItem | Item[] | null—
onValueChange(value, details) => void—
autoHighlightbooleanfalse
itemToStringLabel(item: Item) => string—
filter(item, query, toString) => boolean—
disabledbooleanfalse
namestring—

ComboboxInput

PropTypeDefault
showTriggerbooleantrue
showClearbooleanfalse
disabledbooleanfalse
aria-invalidboolean—

xstyle and className apply to the InputGroup frame, not the <input>. The popup anchors to the frame, so the popup is the frame's width, addons included.

ComboboxContent

PropTypeDefault
side"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end""bottom"
sideOffsetnumber6
align"start" | "center" | "end""start"
alignOffsetnumber0
anchorElement | RefObject<Element>—

The popup is never narrower than 9rem and never taller than 18rem. The list inside scrolls.

ComboboxItem

PropTypeDefault
valueItem—
disabledbooleanfalse

ComboboxGroup

PropTypeDefault
itemsreadonly Item[]—

ComboboxChip

PropTypeDefault
showRemovebooleantrue

comboboxStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
contentThe popup box: width, radius, transition
listThe scrolling list and its padding
itemAn item's row, type and nested corner
itemHighlightedThe highlight fill and inset ring
itemDisabledThe dimmed, inert item
labelA group label
emptyThe empty message
separatorThe hairline between groups
chipsThe multi-select frame, ring and surface
chipOne chip
chipsInputThe bare input inside the chips frame

content carries no surface. The fill and its hairline ring come from popupSurface. It sets --carbon-popup-radius, and item merges popupItem, so a row's corner is that radius less the list's inset. A highlighted item is itemHighlight and itemDisabled is disabled. chips is the frame's box, focusWithinRing, fieldSurface and the drop with the halo beside it on :focus-within, all from states in theme/states.ts.

Data attributes

AttributeOnEffect
data-slot="combobox-content"the popupPopup surface
data-side, data-alignthe popupBase UI writes them. data-side picks the slide direction
data-emptythe popupBase UI writes it when the filter leaves nothing
data-starting-style, data-ending-stylethe popupBase UI writes them while the popup animates in or out
data-slot="input-group"the input frameThe frame ComboboxInput renders
data-popup-openthe frame, inputBase UI writes it while the popup is open
data-slot="combobox-trigger"the chevronWhat interop.css hides beside a clear button
data-slot="combobox-clear"the clear buttonMounted only while there is a value
data-slot="combobox-item"an itemTargets items from a parent
data-highlighted, data-selectedan itemBase UI writes them
data-disabledan item, a chipBase UI writes it
data-slot="combobox-chips"the chips frameTargets the frame from a parent
data-slot="combobox-chip"a chipTargets a chip from a parent