Forms

Combo Box

Autocomplete combo box built on react-aria-components, styled with the quebi design system. Pairs a text input with a filterable dropdown of options, with brand-teal selection and sections.

forminputautocompleteselectdropdowninteractive

Default

A labelled combo box with a filterable list of options.

With description

A hint rendered below the label.

Start typing to filter the list.

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

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,
  InputProps as PrimitiveInputProps,
  ListBoxProps,
  PopoverProps,
} from "react-aria-components"
import {
  Button,
  ComboBoxContext,
  ComboBox as ComboboxPrimitive,
  ListBox,
  useSlottedContext,
} from "react-aria-components"
import { cn } from "@/lib/utils"
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 and reuses the Dropdown surface/items inside a Popover.
 * Selection and focus read in brand teal via the shared dropdown styling.
 */

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

const ComboBox = <T extends object>({ className, ...props }: ComboBoxProps<T>) => {
  return (
    <ComboboxPrimitive
      data-slot="control"
      className={cn("group flex w-full flex-col gap-y-1.5", className)}
      {...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={cn(
        "min-w-(--trigger-width) scroll-py-1 overflow-y-auto overscroll-contain",
        popover?.className,
      )}
      {...popover}
    >
      <ListBox
        layout="stack"
        orientation="vertical"
        className={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",
          className,
        )}
        items={items}
        {...props}
      >
        {children}
      </ListBox>
    </PopoverContent>
  )
}

const ComboBoxInput = (props: PrimitiveInputProps) => {
  const context = useSlottedContext(ComboBoxContext)
  return (
    <span
      data-slot="control"
      className="relative isolate block has-[[data-slot=icon]:last-child]:[&_input]:pe-10"
    >
      <Input {...props} placeholder={props?.placeholder} />
      <Button className="absolute end-0 top-0 grid h-full w-9 cursor-default place-content-center outline-none">
        {!context?.inputValue && (
          <ChevronsUpDown data-slot="icon" className="-me-1 size-4 text-quebi-fg-muted" />
        )}
      </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,
}