# Combobox

> Text input paired with a filterable option list - the marquee Wave 2 component. Sync filter, custom item renderers, and a data-uikit-combobox adapter that boots the vanilla runtime for server-rendered pages.

- Category: navigation
- Status: stable (since 0.3.0)
- A11y pattern: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
- Tokens: --background-quaternary, --background-tertiary, --cta-background, --cta-foreground, --foreground-primary, --foreground-secondary, --border-width-thin
- Playground: https://design.freecodecamp.org/playground#combobox
- npm dependencies: `react@>=18 <20`
- Registry dependencies: [theme](https://design.freecodecamp.org/registry/theme.md)
- Files:
  - `Combobox.tsx` → `src/ui/combobox/Combobox.tsx` (raw: https://design.freecodecamp.org/registry/combobox/Combobox.tsx)
  - `combobox.css` → `src/ui/combobox/combobox.css` (raw: https://design.freecodecamp.org/registry/combobox/combobox.css)

## Install (copy source)

1. Ensure the theme is installed once per project - tokens.css + base.css imported globally, fonts available. See https://design.freecodecamp.org/registry/theme.md and https://design.freecodecamp.org/registry/starter.md.
2. Copy the files below into `src/ui/combobox/` (adjust to your project layout) and import the CSS once from your global stylesheet, e.g. `@import './ui/combobox/combobox.css';`.
3. Colors, spacing and type come from tokens - tailor the component by editing the copied source; recolour by editing tokens.css, not the component CSS.

## Usage

Combobox is a text input paired with a filterable option list - the
marquee Wave 2 component. The React layer is fully controlled: you
own `value`, `inputValue`, and the filtered `items` array; the
component renders the resulting DOM. The vanilla runtime attaches to
`[data-uikit-combobox]`, filters by substring as the user types,
handles arrow-key navigation, and dispatches
`uikit:combobox-change` + `uikit:combobox-input` for server-rendered
pages that want to stay behind native forms.

Async filtering is scheduled for Phase 3D polish - this release covers
the sync-filter case only.

## Helpers

`filterItemsByLabel(items, query)` - exported alongside the component.
Case-insensitive substring match on each `item.label` (falling back to
`item.value` when the label is not a string). Same predicate the
vanilla adapter uses, so React and runtime renderers stay in sync.

## Keyboard (vanilla runtime)

| Key       | Action                      |
| --------- | --------------------------- |
| `↓` / `↑` | Move active option          |
| `Enter`   | Select the active option    |
| `Escape`  | Collapse the listbox        |
| Type      | Filter options by substring |

## Accessibility

Follows the [APG Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/).
The `<input>` carries `role="combobox"`, `aria-autocomplete="list"`,
and `aria-controls` pointing to the listbox id; `aria-expanded`
mirrors the open/closed state. Each option is a `<li role="option">`
with `aria-selected` and optional `aria-disabled`. Always pair with
`aria-label` or `aria-labelledby`.

## Example

```tsx
import { Combobox, filterItemsByLabel } from './ui/combobox/Combobox';
import { useMemo, useState } from 'react';

const ALL = [
  { value: 'rwd', label: 'Responsive Web Design' },
  { value: 'js',  label: 'JavaScript Algorithms' }
];

const [query, setQuery] = useState('');
const [value, setValue] = useState<string | null>(null);
const items = useMemo(() => filterItemsByLabel(ALL, query), [query]);

<Combobox
  inputValue={query}
  onInputValueChange={setQuery}
  value={value}
  onValueChange={setValue}
  items={items}
  placeholder='Pick a certification'
/>
```

## Props

| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `items` | `ComboboxItem[]` | yes | - |  |
| `value` | `string | null` | no | `null` |  |
| `inputValue` | `string` | no | - |  |
| `placeholder` | `string` | no | - |  |
| `disabled` | `boolean` | no | - |  |
| `loading` | `boolean` | no | - | When true, render a `data-part="loading"` row instead of empty/items. Useful during async fetches; pair with `useAsyncComboboxItems` for debounce + cancellation. |
| `error` | `ReactNode` | no | - | Render a `data-part="error"` row with this message. Takes priority over the empty state so transient fetch errors surface clearly. |
| `emptyMessage` | `ReactNode` | no | - | Message for the empty state. Rendered when `items.length === 0` and we're not loading. Defaults to "No results". |
| `loadingMessage` | `ReactNode` | no | - | Message for the loading state. Defaults to "Loading…". |
| `onValueChange` | `((value: string) => void)` | no | - |  |
| `onInputValueChange` | `((inputValue: string) => void)` | no | - |  |
| `renderItem` | `((item: ComboboxItem) => ReactNode)` | no | - |  |
| `aria-label` | `string` | no | - | Defines a string value that labels the current element. |
| `aria-labelledby` | `string` | no | - | Identifies the element (or elements) that labels the current element. |

## Source: Combobox.tsx

```tsx
import React, { forwardRef, useId } from 'react';

export interface ComboboxItem {
  value: string;
  label: React.ReactNode;
  disabled?: boolean;
}

/**
 * Sync-filter helper that matches Zag's default "contains" predicate on
 * each item's label. Non-string labels fall back to the `value` field
 * so custom renderers still filter sanely.
 */
export function filterItemsByLabel<T extends ComboboxItem>(
  items: T[],
  query: string
): T[] {
  const q = query.trim().toLowerCase();
  if (q.length === 0) return items;
  return items.filter(item => {
    const label = typeof item.label === 'string' ? item.label : item.value;
    return label.toLowerCase().includes(q);
  });
}

export interface ComboboxProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  'onChange'
> {
  items: ComboboxItem[];
  value?: string | null;
  inputValue?: string;
  placeholder?: string;
  disabled?: boolean;
  /**
   * When true, render a `data-part="loading"` row instead of empty/items.
   * Useful during async fetches; pair with `useAsyncComboboxItems` for
   * debounce + cancellation.
   */
  loading?: boolean;
  /**
   * Render a `data-part="error"` row with this message. Takes priority
   * over the empty state so transient fetch errors surface clearly.
   */
  error?: React.ReactNode;
  /**
   * Message for the empty state. Rendered when `items.length === 0`
   * and we're not loading. Defaults to "No results".
   */
  emptyMessage?: React.ReactNode;
  /**
   * Message for the loading state. Defaults to "Loading…".
   */
  loadingMessage?: React.ReactNode;
  onValueChange?: (value: string) => void;
  onInputValueChange?: (inputValue: string) => void;
  renderItem?: (item: ComboboxItem) => React.ReactNode;
  'aria-label'?: string;
  'aria-labelledby'?: string;
}

export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
  (
    {
      items,
      value = null,
      inputValue,
      placeholder,
      disabled,
      loading,
      error,
      emptyMessage,
      loadingMessage,
      onValueChange,
      onInputValueChange,
      renderItem,
      className = '',
      id,
      'aria-label': ariaLabel,
      'aria-labelledby': ariaLabelledBy,
      ...rest
    },
    ref
  ) => {
    const reactId = useId();
    const rootId = id ?? `combobox-${reactId}`;
    const listId = `${rootId}-listbox`;
    const classes = ['combobox', className].filter(Boolean).join(' ');
    const showLoading = Boolean(loading);
    const showError = !showLoading && error !== undefined && error !== null;
    const showEmpty = !showLoading && !showError && items.length === 0;
    return (
      <div ref={ref} id={rootId} className={classes} data-part='root' {...rest}>
        <input
          type='text'
          role='combobox'
          className='combobox__input'
          data-part='input'
          aria-autocomplete='list'
          aria-expanded={false}
          aria-controls={listId}
          aria-label={ariaLabel}
          aria-labelledby={ariaLabelledBy}
          placeholder={placeholder}
          disabled={disabled}
          value={inputValue}
          onChange={e => onInputValueChange?.(e.currentTarget.value)}
          readOnly={inputValue !== undefined && !onInputValueChange}
        />
        <ul
          id={listId}
          role='listbox'
          className='combobox__list'
          data-part='listbox'
          aria-busy={showLoading ? true : undefined}
        >
          {showLoading && (
            <li
              className='combobox__item combobox__item--status'
              data-part='loading'
              role='option'
              aria-disabled='true'
              aria-selected='false'
            >
              {loadingMessage ?? 'Loading…'}
            </li>
          )}
          {showError && (
            <li
              className='combobox__item combobox__item--status'
              data-part='error'
              role='option'
              aria-disabled='true'
              aria-selected='false'
            >
              {error}
            </li>
          )}
          {showEmpty && (
            <li
              className='combobox__item combobox__item--status'
              data-part='empty'
              role='option'
              aria-disabled='true'
              aria-selected='false'
            >
              {emptyMessage ?? 'No results'}
            </li>
          )}
          {!showLoading &&
            !showError &&
            items.map(item => {
              const selected = value === item.value;
              return (
                <li
                  key={item.value}
                  role='option'
                  className='combobox__item'
                  data-part='item'
                  data-value={item.value}
                  aria-selected={selected}
                  aria-disabled={item.disabled ? true : undefined}
                  onClick={
                    item.disabled
                      ? undefined
                      : () => onValueChange?.(item.value)
                  }
                >
                  {renderItem ? renderItem(item) : item.label}
                </li>
              );
            })}
        </ul>
      </div>
    );
  }
);
Combobox.displayName = 'Combobox';
```

## Source: combobox.css

```css
.combobox {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-family: var(--font-sans);
  font-size: var(--fs-md);
  color: var(--foreground-primary);
}
.combobox__input {
  width: 100%;
  padding: 6px 10px;
  background: var(--background-quaternary);
  border: var(--border-width-thin) solid var(--foreground-secondary);
  color: inherit;
  font-family: inherit;
  font-size: inherit;
}
.combobox__input:focus-visible {
  outline: none;
  border-color: var(--foreground-primary);
}
.combobox__list {
  list-style: none;
  padding: 4px;
  margin: 0;
  background: var(--background-quaternary);
  border: var(--border-width-thin) solid var(--foreground-secondary);
  max-height: 240px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.combobox__list[hidden] {
  display: none;
}
.combobox__item {
  padding: 6px 10px;
  cursor: pointer;
  user-select: none;
}
.combobox__item[hidden] {
  display: none;
}
.combobox__item:hover:not([aria-disabled='true']) {
  background: var(--background-tertiary);
}
.combobox__item[aria-selected='true'] {
  background: var(--cta-background);
  color: var(--cta-foreground);
}
.combobox__item[aria-disabled='true'] {
  opacity: 0.4;
  cursor: not-allowed;
}
.combobox__item:focus-visible {
  outline: var(--border-width-thin) solid var(--foreground-primary);
  outline-offset: -2px;
}
```

## HTML / vanilla variant

```html
<div class="combobox" role="combobox" aria-expanded="true">
  <input class="combobox__input" type="text" />
  <ul class="combobox__list" role="listbox">
    <li class="combobox__item" role="option">Responsive Web Design</li>
  </ul>
</div>
```

Interactive behaviours for plain HTML come from the vanilla runtime (data-uikit-* attributes): https://design.freecodecamp.org/registry/vanilla.md - or download https://design.freecodecamp.org/cdn/uikit.global.js once and self-host it (do not hotlink).

## For coding agents

This library is distributed as copyable source, not an npm package. Start at https://design.freecodecamp.org/registry/starter.md, discover components via https://design.freecodecamp.org/llms.txt, and copy files into the consuming project. Keep token names intact; recolour by editing the copied tokens.css.
