# Listbox

> Scrollable single- or multi-select list. Ships a React layer for controlled state and a data-uikit-listbox adapter with full keyboard navigation and type-ahead for the vanilla runtime.

- Category: navigation
- Status: stable (since 0.3.0)
- A11y pattern: https://www.w3.org/WAI/ARIA/apg/patterns/listbox/
- Tokens: --background-quaternary, --background-tertiary, --cta-background, --cta-foreground, --foreground-primary, --foreground-secondary, --border-width-thin
- Playground: https://design.freecodecamp.org/playground#listbox
- npm dependencies: `react@>=18 <20`
- Registry dependencies: [theme](https://design.freecodecamp.org/registry/theme.md)
- Files:
  - `Listbox.tsx` → `src/ui/listbox/Listbox.tsx` (raw: https://design.freecodecamp.org/registry/listbox/Listbox.tsx)
  - `listbox.css` → `src/ui/listbox/listbox.css` (raw: https://design.freecodecamp.org/registry/listbox/listbox.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/listbox/` (adjust to your project layout) and import the CSS once from your global stylesheet, e.g. `@import './ui/listbox/listbox.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

Listbox renders a scrollable `<ul role="listbox">` for single or
multi-select choices. The React layer is fully controlled - you own
`value`, the component renders `aria-selected` attributes from it.
The vanilla runtime attaches to `[data-uikit-listbox]` roots and
handles keyboard navigation (arrows, Home/End, Enter/Space to select)
plus 600 ms type-ahead focus jumps.

## Keyboard (vanilla runtime)

| Key               | Action                                               |
| ----------------- | ---------------------------------------------------- |
| `↓` / `↑`         | Move active option                                   |
| `Home` / `End`    | Jump to first / last enabled option                  |
| `Enter` / `Space` | Toggle the active option                             |
| Printable char    | Focus the next option starting with the typed prefix |

## Accessibility

Follows the [APG Listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/).
The root carries `role="listbox"` and `aria-multiselectable="true"`
when in multi-select mode. Disabled items carry `aria-disabled="true"`
and are skipped by focus and selection. Always supply an `aria-label`
or `aria-labelledby` - there is no built-in visible label.

## Example

```tsx
import { Listbox } from './ui/listbox/Listbox';
import { useState } from 'react';

const ITEMS = [
  { value: 'frontend', label: 'Frontend' },
  { value: 'backend',  label: 'Backend' }
];

const [value, setValue] = useState<string | string[]>('frontend');

<Listbox items={ITEMS} value={value} onValueChange={setValue} />
```

## Props

| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `items` | `ListboxItem[]` | yes | - |  |
| `value` | `string | string[] | null` | no | `null` |  |
| `selectionMode` | `enum` | no | `single` |  |
| `onValueChange` | `((value: string | string[]) => void)` | no | - |  |

## Source: Listbox.tsx

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

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

export type ListboxSelectionMode = 'single' | 'multiple';

export type ListboxValue = string | string[] | null;

export interface ListboxProps extends Omit<
  React.HTMLAttributes<HTMLUListElement>,
  'onChange'
> {
  items: ListboxItem[];
  value?: string | string[] | null;
  selectionMode?: ListboxSelectionMode;
  onValueChange?: (value: string | string[]) => void;
}

function isSelected(
  itemValue: string,
  selectionMode: ListboxSelectionMode,
  value: ListboxValue
): boolean {
  if (value == null) return false;
  if (selectionMode === 'multiple') {
    return Array.isArray(value) && value.includes(itemValue);
  }
  return typeof value === 'string' && value === itemValue;
}

export const Listbox = forwardRef<HTMLUListElement, ListboxProps>(
  (
    {
      items,
      value = null,
      selectionMode = 'single',
      onValueChange,
      className = '',
      ...rest
    },
    ref
  ) => {
    const classes = ['listbox', className].filter(Boolean).join(' ');
    const multi = selectionMode === 'multiple';
    const pick = (itemValue: string): void => {
      if (!onValueChange) return;
      if (multi) {
        const current = Array.isArray(value) ? value : [];
        const next = current.includes(itemValue)
          ? current.filter(v => v !== itemValue)
          : [...current, itemValue];
        onValueChange(next);
      } else {
        onValueChange(itemValue);
      }
    };
    return (
      <ul
        ref={ref}
        className={classes}
        role='listbox'
        aria-multiselectable={multi ? true : undefined}
        {...rest}
      >
        {items.map(item => {
          const selected = isSelected(item.value, selectionMode, value);
          return (
            <li
              key={item.value}
              role='option'
              className='listbox__option'
              data-part='option'
              data-value={item.value}
              aria-selected={selected}
              aria-disabled={item.disabled ? true : undefined}
              onClick={item.disabled ? undefined : () => pick(item.value)}
            >
              {item.label}
            </li>
          );
        })}
      </ul>
    );
  }
);
Listbox.displayName = 'Listbox';
```

## Source: listbox.css

```css
.listbox {
  list-style: none;
  padding: 4px;
  margin: 0;
  font-family: var(--font-sans);
  font-size: var(--fs-md);
  color: var(--foreground-primary);
  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;
}
.listbox__option {
  padding: 6px 10px;
  cursor: pointer;
  user-select: none;
  display: flex;
  align-items: center;
  gap: 8px;
}
.listbox__option:hover:not([aria-disabled='true']) {
  background: var(--background-tertiary);
}
.listbox__option[aria-selected='true'] {
  background: var(--cta-background);
  color: var(--cta-foreground);
}
.listbox__option[aria-disabled='true'] {
  opacity: 0.4;
  cursor: not-allowed;
}
.listbox__option:focus-visible {
  outline: var(--border-width-thin) solid var(--foreground-primary);
  outline-offset: -2px;
}
```

## HTML / vanilla variant

```html
<ul class="listbox" role="listbox">
  <li class="listbox__option" role="option" aria-selected="true">Responsive Web Design</li>
  <li class="listbox__option" role="option">JavaScript Algorithms</li>
</ul>
```

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.
