component — selection

Combo Box

Autocomplete combo box built on react-aria-components, styled with the quebi design system. Pairs an underlined text input with a filterable dropdown of options, with sections. The list opens on focus rather than on the first keystroke; pass menuTrigger="input" or "manual" to opt out.

  • form
  • input
  • autocomplete
  • select
  • dropdown
  • interactive

08 examples

Default

A labelled combo box with a filterable list of options. The list opens as soon as the input is focused.

Opens on typing

Pass menuTrigger="input" for react-aria's own default: the list stays shut until you type, and the chevron is the only way to see it whole.

With description

A hint rendered below the label.

Focus the field to see every option; type to filter.

With descriptions per item

Sections

Options grouped under headers.

Disabled

Validation

Required combo box surfacing an error message.

Please select a fruit.

Controlled

Selected: mango

source

combo-box.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { ChevronsUpDown } from "lucide-react"
import type {
  ComboBoxProps as ComboboxPrimitiveProps,
  ListBoxProps,
  PopoverProps,
} from "react-aria-components"
import {
  Button,
  ComboBox as ComboboxPrimitive,
  composeRenderProps,
  ListBox,
} from "react-aria-components"
import { cn } from "@/lib/utils"
import { fieldStyles } from "@/components/field"
import {
  DropdownDescription,
  DropdownItem,
  DropdownLabel,
  DropdownSection,
} from "@/components/dropdown"
import { PopoverContent } from "@/components/popover"
import { Input } from "@/components/input"

/**
 * Combo Box — quebi design system
 *
 * An autocomplete combo box: a quebi-styled text input paired with a filterable
 * dropdown of options. Built on react-aria-components, it composes the quebi
 * Input for the control — so it is framed like every other field — and
 * reuses the Dropdown surface/items inside a Popover.
 *
 * The list opens as soon as the input is focused (`menuTrigger="focus"`), so
 * tabbing in shows there is a list instead of making the first keystroke both
 * reveal and filter it. Pass `menuTrigger="input"` for react-aria's own default
 * (open on typing) or `"manual"` to leave the chevron as the only way in.
 */

interface ComboBoxProps<T extends object> extends Omit<ComboboxPrimitiveProps<T>, "children"> {
  children: React.ReactNode
}

const ComboBox = <T extends object>({
  className,
  menuTrigger = "focus",
  ...props
}: ComboBoxProps<T>) => {
  return (
    <ComboboxPrimitive
      data-slot="control"
      menuTrigger={menuTrigger}
      className={composeRenderProps(className, (resolved) =>
        cn("group", fieldStyles, resolved),
      )}
      {...props}
    />
  )
}

interface ComboBoxListProps<T extends object>
  extends Omit<ListBoxProps<T>, "layout" | "orientation">,
    Pick<PopoverProps, "placement"> {
  popover?: Omit<PopoverProps, "children">
}

const ComboBoxContent = <T extends object>({
  children,
  items,
  className,
  popover,
  ...props
}: ComboBoxListProps<T>) => {
  return (
    <PopoverContent
      placement={popover?.placement ?? "bottom"}
      className={composeRenderProps(popover?.className, (resolved) =>
        cn(
          "min-w-(--trigger-width) scroll-py-1 overflow-y-auto overscroll-contain",
          resolved,
        ),
      )}
      {...popover}
    >
      <ListBox
        layout="stack"
        orientation="vertical"
        className={composeRenderProps(className, (resolved) =>
          cn(
            "grid max-h-96 w-full grid-cols-[auto_1fr] flex-col gap-y-1 overflow-y-auto p-1 outline-hidden *:[[role='group']+[role=group]]:mt-4 *:[[role='group']+[role=separator]]:mt-1",
            resolved,
          ),
        )}
        items={items}
        {...props}
      >
        {children}
      </ListBox>
    </PopoverContent>
  )
}

/**
 * Takes `Input`'s props, including `size`.
 *
 * The chevron stays rendered whatever the input holds: it is the button's only
 * visible affordance, and hiding it once you type left a 36px-wide invisible
 * toggle over the end of the field — and moved the input's end padding with it.
 * The chevron sits on the field's end edge, where `Select` puts its own.
 */
const ComboBoxInput = (props: React.ComponentProps<typeof Input>) => {
  return (
    <span
      data-slot="control"
      className="relative isolate block has-[[data-slot=icon]:last-child]:[&_input]:pe-8"
    >
      <Input {...props} placeholder={props?.placeholder} />
      <Button className="absolute end-0 top-0 flex h-full w-8 cursor-default items-center justify-end outline-none">
        <ChevronsUpDown data-slot="icon" className="size-4 text-quebi-fg-subtle" />
      </Button>
    </span>
  )
}

const ComboBoxSection = DropdownSection
const ComboBoxItem = DropdownItem
const ComboBoxLabel = DropdownLabel
const ComboBoxDescription = DropdownDescription

export type { ComboBoxListProps, ComboBoxProps }
export {
  ComboBox,
  ComboBoxContent,
  ComboBoxDescription,
  ComboBoxInput,
  ComboBoxItem,
  ComboBoxLabel,
  ComboBoxSection,
}