# Listbox

Persistent selection list - keyboard navigable, single or multi-select.

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

## 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/listbox/listbox.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 { Listbox } from './ui/listbox/Listbox';
import { useState } from 'react';

export function Example() {
  const ITEMS = [
    { value: 'frontend', label: 'Frontend' },
    { value: 'backend', label: 'Backend' }
  ];

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

  return (
    <Listbox
      aria-label='Learning track'
      items={ITEMS}
      value={value}
      onValueChange={setValue}
    />
  );
}
```

## Interaction guidance

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

## Component source

### src/ui/listbox/Listbox.tsx

Source: https://design.freecodecamp.org/registry/listbox/Listbox.tsx

```tsx
import React, { forwardRef, useEffect, useId, useState } 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 = '',
      onKeyDown,
      ...rest
    },
    ref
  ) => {
    const classes = ['listbox', className].filter(Boolean).join(' ');
    const multi = selectionMode === 'multiple';
    const listId = useId();
    const [active, setActive] = useState<string | null>(null);
    const enabled = items.filter(item => !item.disabled);
    const focusedValue =
      active ?? (typeof value === 'string' ? value : null) ?? enabled[0]?.value;
    const activeIndex = items.findIndex(
      item => item.value === focusedValue && !item.disabled
    );
    useEffect(() => {
      const option = document.getElementById(`${listId}-${activeIndex}`);
      if (option?.parentElement === document.activeElement) {
        option.scrollIntoView?.({ block: 'nearest' });
      }
    }, [activeIndex, listId]);
    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'
        tabIndex={0}
        aria-activedescendant={
          activeIndex >= 0 ? `${listId}-${activeIndex}` : undefined
        }
        onKeyDown={event => {
          const { key } = event;
          onKeyDown?.(event);
          if (event.defaultPrevented || !enabled.length) return;
          const current = enabled.findIndex(
            item => item.value === focusedValue
          );
          let next = current;
          if (key === 'ArrowDown') next = (current + 1) % enabled.length;
          else if (key === 'ArrowUp')
            next = (current - 1 + enabled.length) % enabled.length;
          else if (key === 'Home') next = 0;
          else if (key === 'End') next = enabled.length - 1;
          else if (key === 'Enter' || key === ' ') {
            event.preventDefault();
            if (activeIndex >= 0) pick(items[activeIndex]!.value);
            return;
          } else return;
          event.preventDefault();
          setActive(enabled[next]!.value);
        }}
        aria-multiselectable={multi ? true : undefined}
        {...rest}
      >
        {items.map((item, index) => {
          const selected = isSelected(item.value, selectionMode, value);
          return (
            <li
              key={item.value}
              role='option'
              id={`${listId}-${index}`}
              data-highlighted={activeIndex === index ? '' : undefined}
              className='listbox__option'
              data-part='option'
              data-value={item.value}
              aria-selected={selected}
              aria-disabled={item.disabled ? true : undefined}
              onClick={
                item.disabled
                  ? undefined
                  : () => {
                      setActive(item.value);
                      pick(item.value);
                    }
              }
            >
              {item.label}
            </li>
          );
        })}
      </ul>
    );
  }
);
Listbox.displayName = 'Listbox';
```

### src/ui/listbox/listbox.css

Source: https://design.freecodecamp.org/registry/listbox/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;
}
.listbox:focus-visible {
  outline: 2px solid var(--highlight-color);
  outline-offset: 2px;
}
.listbox:focus-visible .listbox__option[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

