Skip to content

Combobox

A filterable single-select implementing the ARIA 1.2 combobox pattern, with active-descendant browsing that never moves focus out of the input.

FormsadvancedFeaturedformautocompletefilterlistboxsearch

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

The exact file rendered in the preview above.

'use client'

import { useState } from 'react'
import { Combobox } from '@/components/ui/combobox'
import { Field } from '@/components/ui/field'
import { DemoColumn, DemoStage } from './_kit'

const repositories = [
  { value: 'foundry-core', label: 'foundry/core', description: 'Design tokens and primitives' },
  { value: 'foundry-blocks', label: 'foundry/blocks', description: 'Sections and navigation' },
  { value: 'foundry-docs', label: 'foundry/docs', description: 'Documentation site' },
  { value: 'foundry-cli', label: 'foundry/cli', description: 'Component scaffolding' },
  { value: 'acme-platform', label: 'acme/platform', description: 'Product monorepo' },
  { value: 'acme-infra', label: 'acme/infra', description: 'Terraform modules' },
  {
    value: 'acme-legacy',
    label: 'acme/legacy',
    description: 'Archived — read only',
    disabled: true,
  },
]

export default function ComboboxDemo() {
  const [value, setValue] = useState('foundry-blocks')

  return (
    <DemoStage>
      <DemoColumn>
        <Field
          name="repository"
          label="Repository"
          hint="Type to filter. Arrow keys browse without leaving the input."
          required
        >
          {(field) => (
            <Combobox
              id={field.id}
              name={field.name}
              aria-describedby={field['aria-describedby']}
              options={repositories}
              value={value}
              onValueChange={setValue}
              placeholder="Search repositories"
            />
          )}
        </Field>

        <Field name="mirror" label="Mirror target" error="Choose a repository to mirror into.">
          {(field) => (
            <Combobox
              id={field.id}
              name={field.name}
              aria-describedby={field['aria-describedby']}
              invalid
              options={repositories}
              placeholder="Search repositories"
              emptyMessage="No repository matches that name."
            />
          )}
        </Field>

        <p className="text-xs text-ink-muted">
          Selected value: <code className="font-mono text-ink">{value}</code>
        </p>
      </DemoColumn>
    </DemoStage>
  )
}

components/demos/combobox.tsx

'use client'

import { useState } from 'react'
import { Combobox } from '@/components/ui/combobox'
import { Field } from '@/components/ui/field'
import { DemoColumn, DemoStage } from './_kit'

const repositories = [
  { value: 'foundry-core', label: 'foundry/core', description: 'Design tokens and primitives' },
  { value: 'foundry-blocks', label: 'foundry/blocks', description: 'Sections and navigation' },
  { value: 'foundry-docs', label: 'foundry/docs', description: 'Documentation site' },
  { value: 'foundry-cli', label: 'foundry/cli', description: 'Component scaffolding' },
  { value: 'acme-platform', label: 'acme/platform', description: 'Product monorepo' },
  { value: 'acme-infra', label: 'acme/infra', description: 'Terraform modules' },
  {
    value: 'acme-legacy',
    label: 'acme/legacy',
    description: 'Archived — read only',
    disabled: true,
  },
]

export default function ComboboxDemo() {
  const [value, setValue] = useState('foundry-blocks')

  return (
    <DemoStage>
      <DemoColumn>
        <Field
          name="repository"
          label="Repository"
          hint="Type to filter. Arrow keys browse without leaving the input."
          required
        >
          {(field) => (
            <Combobox
              id={field.id}
              name={field.name}
              aria-describedby={field['aria-describedby']}
              options={repositories}
              value={value}
              onValueChange={setValue}
              placeholder="Search repositories"
            />
          )}
        </Field>

        <Field name="mirror" label="Mirror target" error="Choose a repository to mirror into.">
          {(field) => (
            <Combobox
              id={field.id}
              name={field.name}
              aria-describedby={field['aria-describedby']}
              invalid
              options={repositories}
              placeholder="Search repositories"
              emptyMessage="No repository matches that name."
            />
          )}
        </Field>

        <p className="text-xs text-ink-muted">
          Selected value: <code className="font-mono text-ink">{value}</code>
        </p>
      </DemoColumn>
    </DemoStage>
  )
}

components/ui/combobox.tsx

'use client'

import { useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { ChevronDown, Check, Search } from 'lucide-react'
import { cn } from '@/lib/cn'
import { useDismiss } from '@/hooks/use-dismiss'
import { controlSurface } from './input'

/**
 * Combobox
 *
 * Implements the ARIA 1.2 combobox pattern with a filterable listbox popup:
 * the text input owns `role="combobox"`, `aria-expanded` and
 * `aria-activedescendant`, and the list is a real `role="listbox"` whose
 * options carry `aria-selected`.
 *
 * Focus never leaves the input, which is what makes arrow-key browsing feel
 * right and keeps the on-screen keyboard open on mobile.
 */
export interface ComboboxOption {
  value: string
  label: string
  description?: string
  disabled?: boolean
}

export interface ComboboxProps {
  options: ComboboxOption[]
  id?: string
  name?: string
  value?: string
  defaultValue?: string
  placeholder?: string
  emptyMessage?: string
  disabled?: boolean
  invalid?: boolean
  className?: string
  'aria-describedby'?: string
  onValueChange?: (value: string) => void
}

export function Combobox({
  options,
  id = 'combobox',
  name,
  value: controlledValue,
  defaultValue = '',
  placeholder = 'Select an option',
  emptyMessage = 'No matches found.',
  disabled = false,
  invalid = false,
  className,
  onValueChange,
  ...aria
}: ComboboxProps) {
  const [open, setOpen] = useState(false)
  const [query, setQuery] = useState('')
  const [activeIndex, setActiveIndex] = useState(0)
  const [uncontrolled, setUncontrolled] = useState(defaultValue)
  const value = controlledValue ?? uncontrolled

  const rootRef = useRef<HTMLDivElement>(null)
  const inputRef = useRef<HTMLInputElement>(null)
  const listId = `${id}-listbox`

  const selected = options.find((option) => option.value === value)

  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase()
    if (!q) return options
    return options.filter(
      (option) =>
        option.label.toLowerCase().includes(q) ||
        option.description?.toLowerCase().includes(q) ||
        option.value.toLowerCase().includes(q),
    )
  }, [options, query])

  useDismiss([rootRef], open, () => {
    setOpen(false)
    setQuery('')
  })

  const select = (option: ComboboxOption) => {
    if (option.disabled) return
    if (controlledValue === undefined) setUncontrolled(option.value)
    onValueChange?.(option.value)
    setOpen(false)
    setQuery('')
    inputRef.current?.focus()
  }

  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
      event.preventDefault()
      if (!open) {
        setOpen(true)
        setActiveIndex(0)
        return
      }
      const direction = event.key === 'ArrowDown' ? 1 : -1
      setActiveIndex((current) => {
        if (filtered.length === 0) return 0
        return (current + direction + filtered.length) % filtered.length
      })
    } else if (event.key === 'Enter') {
      if (!open) return
      event.preventDefault()
      const option = filtered[activeIndex]
      if (option) select(option)
    } else if (event.key === 'Home' && open) {
      event.preventDefault()
      setActiveIndex(0)
    } else if (event.key === 'End' && open) {
      event.preventDefault()
      setActiveIndex(Math.max(0, filtered.length - 1))
    } else if (event.key === 'Tab') {
      setOpen(false)
    }
  }

  const activeOption = filtered[activeIndex]

  return (
    <div ref={rootRef} className={cn('relative', className)}>
      {name ? <input type="hidden" name={name} value={value} /> : null}
      <div className="relative flex items-center">
        <Search
          className="pointer-events-none absolute left-3 size-4 text-ink-subtle"
          aria-hidden="true"
        />
        <input
          ref={inputRef}
          id={id}
          role="combobox"
          type="text"
          autoComplete="off"
          disabled={disabled}
          aria-expanded={open}
          aria-controls={listId}
          aria-autocomplete="list"
          aria-activedescendant={
            open && activeOption ? `${id}-option-${activeOption.value}` : undefined
          }
          aria-invalid={invalid || undefined}
          aria-describedby={aria['aria-describedby']}
          placeholder={placeholder}
          value={open ? query : (selected?.label ?? '')}
          onChange={(event) => {
            setQuery(event.target.value)
            setActiveIndex(0)
            if (!open) setOpen(true)
          }}
          onFocus={() => setOpen(true)}
          onKeyDown={onKeyDown}
          className={cn(
            controlSurface,
            'h-control cursor-default pr-9 pl-9 text-sm',
            invalid && 'border-danger bg-danger-soft',
          )}
        />
        <button
          type="button"
          tabIndex={-1}
          aria-hidden="true"
          disabled={disabled}
          onClick={() => {
            setOpen((o) => !o)
            inputRef.current?.focus()
          }}
          className="absolute right-2 flex size-6 items-center justify-center rounded-sm text-ink-subtle"
        >
          <ChevronDown
            className={cn('size-4 transition-transform duration-150', open && 'rotate-180')}
          />
        </button>
      </div>

      {open ? (
        <ul
          id={listId}
          role="listbox"
          aria-label="Options"
          className="absolute z-50 mt-1 max-h-64 w-full overflow-y-auto rounded-md border border-line bg-surface-raised p-1 shadow-md thin-scrollbar"
        >
          {filtered.length === 0 ? (
            <li className="px-3 py-6 text-center text-sm text-ink-muted">{emptyMessage}</li>
          ) : (
            filtered.map((option, index) => (
              <li
                key={option.value}
                id={`${id}-option-${option.value}`}
                role="option"
                aria-selected={option.value === value}
                aria-disabled={option.disabled || undefined}
                onMouseEnter={() => setActiveIndex(index)}
                onPointerDown={(event) => {
                  event.preventDefault()
                  select(option)
                }}
                className={cn(
                  'flex cursor-pointer items-start gap-2 rounded-sm px-2.5 py-2 text-sm',
                  index === activeIndex && 'bg-surface-sunken',
                  option.disabled && 'cursor-not-allowed opacity-50',
                )}
              >
                <Check
                  className={cn(
                    'mt-0.5 size-4 shrink-0 text-accent',
                    option.value === value ? 'opacity-100' : 'opacity-0',
                  )}
                  aria-hidden="true"
                />
                <span className="min-w-0">
                  <span className="block truncate font-medium text-ink">{option.label}</span>
                  {option.description ? (
                    <span className="block truncate text-xs text-ink-muted">
                      {option.description}
                    </span>
                  ) : null}
                </span>
              </li>
            ))
          )}
        </ul>
      ) : null}
    </div>
  )
}

hooks/use-dismiss.ts

'use client'

import { useEffect, type RefObject } from 'react'

/**
 * Closes a layer on Escape or on a pointer press outside it.
 *
 * `pointerdown` is used rather than `click` so that dragging a text selection
 * out of a popover does not dismiss it, and Escape is captured on the document
 * so it works even when focus has moved into a nested portal.
 */
export function useDismiss(
  refs: Array<RefObject<HTMLElement | null>>,
  active: boolean,
  onDismiss: () => void,
  options: { escape?: boolean; outside?: boolean } = {},
): void {
  const { escape = true, outside = true } = options

  useEffect(() => {
    if (!active) return

    const onKeyDown = (event: KeyboardEvent) => {
      if (!escape) return
      if (event.key === 'Escape') {
        event.stopPropagation()
        onDismiss()
      }
    }

    const onPointerDown = (event: PointerEvent) => {
      if (!outside) return
      const target = event.target as Node | null
      if (!target) return
      const inside = refs.some((ref) => ref.current?.contains(target))
      if (!inside) onDismiss()
    }

    document.addEventListener('keydown', onKeyDown)
    document.addEventListener('pointerdown', onPointerDown)
    return () => {
      document.removeEventListener('keydown', onKeyDown)
      document.removeEventListener('pointerdown', onPointerDown)
    }
    // `refs` is a stable-length array supplied by the caller at each layer.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [active, escape, outside, onDismiss])
}

/** Locks body scroll while a modal layer is open, without layout shift. */
export function useScrollLock(active: boolean): void {
  useEffect(() => {
    if (!active) return
    const { body, documentElement } = document
    const scrollBarWidth = window.innerWidth - documentElement.clientWidth
    const previousOverflow = body.style.overflow
    const previousPadding = body.style.paddingRight

    body.style.overflow = 'hidden'
    if (scrollBarWidth > 0) body.style.paddingRight = `${scrollBarWidth}px`

    return () => {
      body.style.overflow = previousOverflow
      body.style.paddingRight = previousPadding
    }
  }, [active])
}

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For long option sets where typing beats scrolling — repositories, countries, users. Focus stays in the text field for the entire interaction, so the on-screen keyboard stays open on mobile and typing is never interrupted.

  • Filtering matches label, description and value, so a user can find "foundry/blocks" by typing "sections".
  • Selection updates a hidden input when `name` is set, so the component works inside a plain form submission.
  • Keep the empty message specific: "No repository matches that name" beats "No results".

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Filterable list
  • Option descriptions
  • Disabled options
  • Custom empty message
  • Invalid

Accessibility

Roles
The input owns `role="combobox"`, `aria-expanded`, `aria-controls` and `aria-autocomplete="list"`; the popup is a real `role="listbox"`.
Active descendant
Arrow keys move `aria-activedescendant` rather than DOM focus, which is what keeps typing uninterrupted.
Keyboard
Arrow keys browse, Home and End jump, Enter selects, Escape and Tab close.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.