# CommandPalette

> Keyboard-driven launcher. Search input + grouped filterable list + shortcut badges. Arrow keys navigate, Enter selects, Escape closes.

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

Use CommandPalette as the keyboard-first entry point to every action
in your app. It mirrors the Raycast / VS Code launcher pattern:
open with `⌘K`, type a few characters, press Enter. Caller owns the
`open` state and the `onSelect` routing.

## Usage

```tsx
import { CommandPalette } from './ui/command-palette/CommandPalette';
const groups = [
  {
    label: 'Navigation',
    items: [
      { id: 'home', label: 'Go home', shortcut: 'g h' },
      { id: 'settings', label: 'Open settings', shortcut: ', ,' }
    ]
  },
  {
    label: 'Actions',
    items: [{ id: 'new', label: 'New task', icon: '+' }]
  }
];

<CommandPalette
  open={open}
  onClose={() => setOpen(false)}
  groups={groups}
  onSelect={id => {
    setOpen(false);
    route(id);
  }}
/>;
```

## Accessibility

Root is `role="dialog" aria-modal="true"`. The input advertises
`aria-autocomplete="list"`; each item carries `role="option"` with
`aria-selected` reflecting the keyboard cursor. Arrow Up/Down moves
the cursor, Enter fires `onSelect`, Escape fires `onClose`. Clicking
outside the palette closes it.

## Example

```tsx
import { CommandPalette } from './ui/command-palette/CommandPalette';

const GROUPS = [
  { label: 'Navigation', items: [
    { id: 'curriculum', label: 'Go to curriculum', shortcut: 'G C' }
  ]}
];

<CommandPalette
  open={open}
  onClose={() => setOpen(false)}
  onSelect={id => navigate(id)}
  groups={GROUPS}
  placeholder='Type a command or search…'
/>
```

## Props

| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `open` | `boolean` | yes | - |  |
| `onClose` | `() => void` | yes | - |  |
| `onSelect` | `(id: string) => void` | yes | - |  |
| `groups` | `readonly CommandPaletteGroup[]` | yes | - |  |
| `placeholder` | `string` | no | `Type a command…` |  |
| `emptyState` | `ReactNode` | no | - | Slot rendered when `groups` is empty (after filtering). |
| `value` | `string` | no | - | Controlled search value. Omit for uncontrolled. |
| `onValueChange` | `((next: string) => void)` | no | - |  |
| `className` | `string` | no | `` |  |

## Source: CommandPalette.tsx

```tsx
import React, { useEffect, useMemo, useRef, useState } from 'react';

export interface CommandPaletteItem {
  id: string;
  label: React.ReactNode;
  icon?: React.ReactNode;
  shortcut?: string;
  keywords?: string;
}

export interface CommandPaletteGroup {
  label: React.ReactNode;
  items: readonly CommandPaletteItem[];
}

export interface CommandPaletteProps {
  open: boolean;
  onClose: () => void;
  onSelect: (id: string) => void;
  groups: readonly CommandPaletteGroup[];
  placeholder?: string;
  /** Slot rendered when `groups` is empty (after filtering). */
  emptyState?: React.ReactNode;
  /** Controlled search value. Omit for uncontrolled. */
  value?: string;
  onValueChange?: (next: string) => void;
  className?: string;
}

interface FlatItem {
  readonly id: string;
  readonly label: React.ReactNode;
}

const searchString = (item: CommandPaletteItem): string => {
  const parts: string[] = [];
  if (typeof item.label === 'string') parts.push(item.label);
  if (item.keywords) parts.push(item.keywords);
  return parts.join(' ').toLowerCase();
};

const filterGroups = (
  groups: readonly CommandPaletteGroup[],
  query: string
): readonly CommandPaletteGroup[] => {
  if (query.trim() === '') return groups;
  const needle = query.toLowerCase();
  return groups
    .map(group => ({
      label: group.label,
      items: group.items.filter(item => searchString(item).includes(needle))
    }))
    .filter(group => group.items.length > 0);
};

const flattenItems = (
  groups: readonly CommandPaletteGroup[]
): readonly FlatItem[] =>
  groups.flatMap(group =>
    group.items.map(item => ({ id: item.id, label: item.label }))
  );

export const CommandPalette = ({
  open,
  onClose,
  onSelect,
  groups,
  placeholder = 'Type a command…',
  emptyState,
  value,
  onValueChange,
  className = ''
}: CommandPaletteProps): React.ReactElement | null => {
  const isControlled = value !== undefined;
  const [internal, setInternal] = useState('');
  const query = isControlled ? value : internal;
  const setQuery = (next: string): void => {
    if (!isControlled) setInternal(next);
    onValueChange?.(next);
  };

  const filtered = useMemo(() => filterGroups(groups, query), [groups, query]);
  const flat = useMemo(() => flattenItems(filtered), [filtered]);
  const [activeIndex, setActiveIndex] = useState(0);
  const activeRef = useRef<HTMLLIElement | null>(null);

  useEffect(() => {
    setActiveIndex(0);
  }, [flat.length]);

  useEffect(() => {
    if (!open) return;
    const onKey = (event: KeyboardEvent): void => {
      if (event.key === 'Escape') {
        event.preventDefault();
        onClose();
        return;
      }
      if (event.key === 'ArrowDown') {
        event.preventDefault();
        setActiveIndex(i => Math.min(i + 1, flat.length - 1));
        return;
      }
      if (event.key === 'ArrowUp') {
        event.preventDefault();
        setActiveIndex(i => Math.max(i - 1, 0));
        return;
      }
      if (event.key === 'Enter') {
        const selected = flat[activeIndex];
        if (selected) {
          event.preventDefault();
          onSelect(selected.id);
        }
      }
    };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [open, flat, activeIndex, onClose, onSelect]);

  useEffect(() => {
    activeRef.current?.scrollIntoView({ block: 'nearest' });
  }, [activeIndex]);

  if (!open) return null;

  const classes = ['command-palette', className].filter(Boolean).join(' ');
  const hasMatches = filtered.length > 0;

  let cursor = -1;
  return (
    <div
      className='command-palette__backdrop'
      onClick={event => {
        if (event.target === event.currentTarget) onClose();
      }}
      data-state='open'
    >
      <div
        role='dialog'
        aria-modal='true'
        aria-label='Command palette'
        className={classes}
      >
        <input
          type='text'
          className='command-palette__search'
          placeholder={placeholder}
          value={query}
          onChange={e => setQuery(e.target.value)}
          autoFocus
          aria-autocomplete='list'
        />
        <ul className='command-palette__list' role='listbox'>
          {!hasMatches && emptyState !== undefined && (
            <li className='command-palette__empty'>{emptyState}</li>
          )}
          {filtered.map((group, gi) => (
            <li key={gi} className='command-palette__group'>
              <div className='command-palette__group-label'>{group.label}</div>
              <ul className='command-palette__group-items'>
                {group.items.map(item => {
                  cursor += 1;
                  const isActive = cursor === activeIndex;
                  const capturedCursor = cursor;
                  return (
                    <li
                      key={item.id}
                      ref={isActive ? activeRef : undefined}
                      role='option'
                      aria-selected={isActive}
                      data-active={isActive ? 'true' : undefined}
                      className='command-palette__item'
                      onMouseEnter={() => setActiveIndex(capturedCursor)}
                      onClick={() => onSelect(item.id)}
                    >
                      {item.icon !== undefined && (
                        <span
                          className='command-palette__icon'
                          aria-hidden='true'
                        >
                          {item.icon}
                        </span>
                      )}
                      <span className='command-palette__label'>
                        {item.label}
                      </span>
                      {item.shortcut !== undefined && (
                        <span className='command-palette__shortcut'>
                          {item.shortcut}
                        </span>
                      )}
                    </li>
                  );
                })}
              </ul>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
};
CommandPalette.displayName = 'CommandPalette';
```

## Source: command-palette.css

```css
.command-palette__backdrop {
  position: fixed;
  inset: 0;
  z-index: 9100;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: 64px 16px 16px;
}
.command-palette {
  width: min(560px, 100%);
  max-height: calc(100vh - 96px);
  display: flex;
  flex-direction: column;
  background: var(--background-quaternary);
  color: var(--foreground-primary);
  border: var(--border-width-thin) solid var(--foreground-secondary);
  box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
}
.command-palette__search {
  flex: 0 0 auto;
  appearance: none;
  padding: 14px 16px;
  background: var(--background-quaternary);
  color: var(--foreground-primary);
  border: 0;
  border-bottom: var(--border-width-thin) solid var(--foreground-secondary);
  font-family: var(--font-body);
  font-size: var(--fs-md);
  outline: none;
}
.command-palette__search::placeholder {
  color: var(--foreground-secondary);
}
.command-palette__list {
  flex: 1 1 auto;
  overflow-y: auto;
  margin: 0;
  padding: 4px 0;
  list-style: none;
}
.command-palette__group {
  padding: 4px 0;
}
.command-palette__group + .command-palette__group {
  border-top: var(--border-width-thin) dashed var(--foreground-secondary);
}
.command-palette__group-label {
  padding: 8px 16px 4px;
  font-family: var(--font-heading);
  font-size: var(--fs-xs);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--foreground-secondary);
}
.command-palette__group-items {
  margin: 0;
  padding: 0;
  list-style: none;
}
.command-palette__item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 16px;
  cursor: pointer;
  color: var(--foreground-primary);
}
.command-palette__item[data-active='true'] {
  background: var(--background-tertiary);
}
.command-palette__icon {
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--foreground-secondary);
}
.command-palette__label {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.command-palette__shortcut {
  flex: 0 0 auto;
  padding: 2px 6px;
  font-family: var(--font-mono);
  font-size: var(--fs-xs);
  color: var(--foreground-secondary);
  border: var(--border-width-thin) solid var(--foreground-secondary);
}
.command-palette__empty {
  padding: 24px 16px;
  text-align: center;
  color: var(--foreground-secondary);
}
```

## HTML / vanilla variant

```html
<div class="command-palette" role="dialog">
  <input class="command-palette__search" type="text" />
  <ul class="command-palette__list">…</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.
