# Combobox

Typeahead select. Filters as you type.

Preview: https://design.freecodecamp.org/playground#combobox

## Add to your project

Use React and TypeScript. Required packages: `react@>=18 <20`. No freeCodeCamp package is needed.

Copy the files below to the indicated paths, relative to your project root. If you change the layout, update relative imports too.

Import the CSS once from your application entry. For an entry in src/:

```ts
import './ui/theme/tokens.css';
import './ui/theme/base.css';
import './ui/combobox/combobox.css';
```

The theme is shared: reuse it if already installed. Fonts use /fonts/ URLs on your host. Download the font files listed in https://design.freecodecamp.org/registry/starter.md or change those URLs in your copied tokens.css.

## Example

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

export function Example() {
  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 = filterItemsByLabel(ALL, query);

  return (
    <Combobox
      inputValue={query}
      onInputValueChange={setQuery}
      value={value}
      onValueChange={setValue}
      items={items}
      aria-label='Certification'
      placeholder='Pick a certification'
    />
  );
}
```

## Interaction guidance

Review https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ and test keyboard operation in your project.

## Component source

### src/ui/combobox/Combobox.tsx

Source: https://design.freecodecamp.org/registry/combobox/Combobox.tsx

```tsx
import React, { forwardRef, useEffect, useId, useState } 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 [open, setOpen] = useState(false);
    const [activeValue, setActiveValue] = useState<string | null>(null);
    const [internalInput, setInternalInput] = useState('');
    const enabled = items.filter(item => !item.disabled);
    const activeIndex = items.findIndex(
      item => item.value === activeValue && !item.disabled
    );
    const expanded = open && !disabled;
    const updateInput = (text: string) => {
      setInternalInput(text);
      onInputValueChange?.(text);
    };
    const pick = (item: ComboboxItem) => {
      if (disabled || item.disabled || loading || error) return;
      onValueChange?.(item.value);
      updateInput(typeof item.label === 'string' ? item.label : item.value);
      setOpen(false);
      setActiveValue(null);
    };
    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;
    useEffect(() => {
      if (expanded && !showLoading && !showError && activeIndex >= 0) {
        document
          .getElementById(`${listId}-${activeIndex}`)
          ?.scrollIntoView?.({ block: 'nearest' });
      }
    }, [expanded, showLoading, showError, activeIndex, listId]);
    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={expanded}
          aria-activedescendant={
            expanded && !showLoading && !showError && activeIndex >= 0
              ? `${listId}-${activeIndex}`
              : undefined
          }
          aria-controls={listId}
          aria-label={ariaLabel}
          aria-labelledby={ariaLabelledBy}
          placeholder={placeholder}
          disabled={disabled}
          value={inputValue ?? internalInput}
          onChange={e => {
            updateInput(e.currentTarget.value);
            setActiveValue(null);
            setOpen(true);
          }}
          onClick={() => setOpen(true)}
          onFocus={() => setOpen(true)}
          onBlur={() => setOpen(false)}
          onKeyDown={event => {
            const { key } = event;
            if (disabled) return;
            if (key === 'Escape' || key === 'Tab') {
              setOpen(false);
              setActiveValue(null);
              if (key === 'Escape') event.preventDefault();
              return;
            }
            if (key === 'Enter' && expanded && activeIndex >= 0) {
              event.preventDefault();
              pick(items[activeIndex]!);
              return;
            }
            if (
              !['ArrowDown', 'ArrowUp'].includes(key) ||
              loading ||
              error ||
              !enabled.length
            )
              return;
            event.preventDefault();
            const current = enabled.findIndex(
              item => item.value === activeValue
            );
            const next =
              key === 'ArrowDown'
                ? (current + 1) % enabled.length
                : current < 0
                  ? enabled.length - 1
                  : (current - 1 + enabled.length) % enabled.length;
            setActiveValue(enabled[next]!.value);
            setOpen(true);
          }}
          readOnly={inputValue !== undefined && !onInputValueChange}
        />
        <ul
          id={listId}
          role='listbox'
          hidden={!expanded}
          aria-label={ariaLabel}
          aria-labelledby={ariaLabelledBy}
          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, index) => {
              const selected = value === item.value;
              return (
                <li
                  key={item.value}
                  role='option'
                  id={`${listId}-${index}`}
                  data-highlighted={activeValue === item.value ? '' : undefined}
                  onMouseDown={event => event.preventDefault()}
                  className='combobox__item'
                  data-part='item'
                  data-value={item.value}
                  aria-selected={selected}
                  aria-disabled={disabled || item.disabled ? true : undefined}
                  onClick={
                    disabled || item.disabled ? undefined : () => pick(item)
                  }
                >
                  {renderItem ? renderItem(item) : item.label}
                </li>
              );
            })}
        </ul>
      </div>
    );
  }
);
Combobox.displayName = 'Combobox';
```

### src/ui/combobox/combobox.css

Source: https://design.freecodecamp.org/registry/combobox/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;
}
.combobox__item[data-highlighted] {
  outline: 2px solid var(--highlight-color);
  outline-offset: -2px;
}
```

## Shared source: Theme

### src/ui/theme/tokens.css

Source: https://design.freecodecamp.org/registry/theme/tokens.css

```css
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Light.woff') format('woff');
  font-weight: 300;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Regular.woff') format('woff');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Italic.woff') format('woff');
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Bold.woff') format('woff');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-BoldItalic.woff') format('woff');
  font-weight: 700;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Lato';
  src: url('/fonts/Lato-Black.woff') format('woff');
  font-weight: 900;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Bold.woff2') format('woff2');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-Italic.woff2') format('woff2');
  font-weight: 400;
  font-style: italic;
  font-display: swap;
}
@font-face {
  font-family: 'Hack-ZeroSlash';
  src: url('/fonts/Hack-ZeroSlash-BoldItalic.woff2') format('woff2');
  font-weight: 700;
  font-style: italic;
  font-display: swap;
}

:root {
  --gray-00: #ffffff;
  --gray-00-translucent: rgba(255, 255, 255, 0.85);
  --gray-05: #f5f6f7;
  --gray-10: #dfdfe2;
  --gray-15: #d0d0d5;
  --gray-45: #858591;
  --gray-75: #3b3b4f;
  --gray-80: #2a2a40;
  --gray-85: #1b1b32;
  --gray-90: #0a0a23;
  --gray-90-translucent: rgba(10, 10, 35, 0.85);

  --purple-light: #dbb8ff;
  --purple-mid: #9400d3;
  --purple-dark: #5a01a7;
  --yellow-light: #ffc300;
  --yellow-gold: #ffbf00;
  --yellow-style: #f1be32;
  --yellow-dark: #4d3800;
  --blue-light: #99c9ff;
  --blue-light-translucent: rgba(153, 201, 255, 0.3);
  --blue-mid: #198eee;
  --blue-dark: #002ead;
  --blue-dark-translucent: rgba(0, 46, 173, 0.3);
  --green-light: #acd157;
  --green-dark: #00471b;
  --red-light: #ffadad;
  --red-dark: #850000;
  --love-light: #f8577c;
  --love-dark: #f82153;
  --orange: #eda971;

  --editor-background-light: #fffffe;
  --editor-background-dark: #2a2b40;

  --syntax-keyword: #dbb8ff;
  --syntax-fn: #99c9ff;
  --syntax-string: #acd157;
  --syntax-class: #f1be32;
  --syntax-number: #f78c6c;
  --syntax-tag: #f07178;
  --syntax-operator: #89ddff;
  --syntax-invalid: #ff5370;
  --syntax-comment: #858591;
  --syntax-plain: #eeffff;

  --font-sans:
    'Lato', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  --font-mono: 'Hack-ZeroSlash', 'Fira Mono', Menlo, Consolas, monospace;

  --fs-base: 18px;
  --fs-sm: 16px;
  --fs-md: 18px;
  --fs-lg: 24px;
  --fs-xl: 32px;
  --fs-2xl: 42px;
  --fs-3xl: 56px;
  --fs-display: clamp(2.5rem, 5vw, 3.75rem);

  --lh-tight: 1.2;
  --lh-snug: 1.33;
  --lh-base: 1.42857143;
  --lh-loose: 1.6;

  --fw-light: 300;
  --fw-regular: 400;
  --fw-bold: 700;
  --fw-black: 900;

  --space-0: 0;
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  --space-7: 48px;
  --space-8: 64px;

  --border-width-hair: 1px;
  --border-width-default: 2px;
  --border-width-thick: 3px;
  --radius-none: 0;
  --radius-sm: 2px;

  --focus-outline-color: var(--blue-mid);
  --focus-outline-width: 3px;

  --z-breadcrumbs: 100;
  --z-flash: 150;
  --z-site-header: 200;
  --z-modal: 1050;

  --ease-snap: cubic-bezier(0.2, 0.8, 0.2, 1);
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --dur-fast: 120ms;
  --dur-base: 180ms;
  --dur-slow: 260ms;

  --header-height: 48px;
  --breadcrumbs-height: 32px;
  --sidebar-width: 288px;
  --content-max: 1040px;

  color-scheme: dark;
}

.dark-palette,
:root {
  color-scheme: dark;
  --foreground-primary: var(--gray-00);
  --foreground-secondary: var(--gray-05);
  --foreground-tertiary: var(--gray-10);
  --foreground-quaternary: var(--gray-15);
  --foreground-muted: #b0b0bd;

  --background-primary: var(--gray-90);
  --background-primary-translucent: var(--gray-90-translucent);
  --background-secondary: var(--gray-85);
  --background-tertiary: #33334f;
  --background-quaternary: #4b4b66;

  --highlight-color: var(--blue-light);
  --highlight-background: var(--blue-dark);
  --selection-color: var(--blue-light-translucent);

  --success-color: var(--green-light);
  --success-background: var(--green-dark);
  --danger-color: var(--red-light);
  --danger-background: var(--red-dark);
  --warning-color: var(--yellow-light);
  --warning-background: var(--yellow-dark);
  --purple-color: var(--purple-light);
  --purple-background: var(--purple-dark);
  --love-color: var(--love-light);

  --editor-background: var(--editor-background-dark);

  --cta-background: var(--yellow-gold);
  --cta-foreground: var(--gray-90);

  --surface-elevation-1: rgba(255, 255, 255, 0.045);
  --surface-elevation-2: rgba(255, 255, 255, 0.09);
  --border-soft: var(--background-tertiary);
  --border-strong: var(--background-quaternary);
}

.light-palette {
  --foreground-primary: var(--gray-90);
  --foreground-secondary: var(--gray-85);
  --foreground-tertiary: var(--gray-80);
  --foreground-quaternary: var(--gray-75);
  --foreground-muted: #5a5a68;

  --background-primary: var(--gray-00);
  --background-primary-translucent: var(--gray-00-translucent);
  --background-secondary: var(--gray-05);
  --background-tertiary: #c5c5cc;
  --background-quaternary: #a8a8b4;

  --highlight-color: var(--blue-dark);
  --highlight-background: var(--blue-light);
  --selection-color: var(--blue-dark-translucent);

  --success-color: var(--green-dark);
  --success-background: var(--green-light);
  --danger-color: var(--red-dark);
  --danger-background: var(--red-light);
  --warning-color: var(--yellow-dark);
  --warning-background: var(--yellow-light);
  --purple-color: var(--purple-dark);
  --purple-background: var(--purple-light);
  --love-color: var(--love-dark);

  --editor-background: var(--editor-background-dark);

  --cta-background: var(--yellow-gold);
  --cta-foreground: var(--gray-90);

  --surface-elevation-1: rgba(10, 10, 35, 0.05);
  --surface-elevation-2: rgba(10, 10, 35, 0.09);
  --border-soft: var(--background-tertiary);
  --border-strong: var(--background-quaternary);

  color-scheme: light;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  font-size: var(--fs-md);
  font-family: var(--font-sans);
  line-height: var(--lh-base);
  color: var(--foreground-primary);
  background: var(--background-primary);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  scroll-behavior: smooth;
  scroll-padding-top: calc(var(--header-height) + 24px);
}

body {
  margin: 0;
  font-family: var(--font-sans);
  color: var(--foreground-primary);
  background: var(--background-primary);
}

::selection {
  background: var(--selection-color);
}

h1,
h2,
h3,
h4,
h5,
h6 {
  font-family: var(--font-sans);
  font-weight: var(--fw-bold);
  color: var(--foreground-primary);
  line-height: var(--lh-snug);
  margin: 0 0 12px 0;
}
h1 {
  font-size: var(--fs-3xl);
  line-height: var(--lh-tight);
  letter-spacing: -0.01em;
}
h2 {
  font-size: var(--fs-2xl);
  letter-spacing: -0.005em;
}
h3 {
  font-size: var(--fs-xl);
}
h4 {
  font-size: var(--fs-lg);
}
h5 {
  font-size: var(--fs-md);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
h6 {
  font-size: var(--fs-sm);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--foreground-muted);
  font-family: var(--font-mono);
}

p {
  margin: 0 0 12px 0;
}

a {
  color: var(--highlight-color);
  text-decoration: underline;
  text-underline-position: under;
  text-underline-offset: 0.1em;
}
a:hover {
  color: var(--foreground-primary);
}

code,
pre,
kbd,
samp {
  font-family: var(--font-mono);
  font-size: 16px;
}
code {
  background: var(--background-tertiary);
  color: var(--foreground-tertiary);
}
:not(pre) > code {
  border: 1px solid var(--background-quaternary);
  padding: 1px 4px;
  overflow-wrap: anywhere;
  word-break: break-word;
}
pre {
  background: var(--editor-background);
  color: var(--foreground-tertiary);
  padding: 14px 16px;
  font-size: 14px;
  line-height: var(--lh-base);
  max-width: 100%;
  overflow-x: auto;
  margin: 0;
}
pre code {
  display: block;
  width: max-content;
  min-width: 100%;
  background: transparent;
  border: 0;
  padding: 0;
}

:focus-visible {
  outline: var(--focus-outline-width) solid var(--focus-outline-color);
  outline-offset: 0;
}

hr {
  border: 0;
  border-top: 1px solid var(--background-quaternary);
  margin: 24px 0;
}

::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}
::-webkit-scrollbar-track {
  background: var(--background-primary);
}
::-webkit-scrollbar-thumb {
  background: var(--background-quaternary);
  border: 2px solid var(--background-primary);
}
::-webkit-scrollbar-thumb:hover {
  background: var(--foreground-muted);
}
```

### src/ui/theme/base.css

Source: https://design.freecodecamp.org/registry/theme/base.css

```css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
```

## Adapting this component

Keep the component's semantics and keyboard behavior. Use the CSS variables to change its appearance. Check the result in your project; copied source does not receive automatic updates.

Source revision: 37aae52 (2026-09-08). Component source: BSD-3-Clause. Preserve the license notice: https://design.freecodecamp.org/license.txt.

Design rules: https://design.freecodecamp.org/handbook.md

