# Dropdown

> A menu button pattern with keyboard navigation and outside-click dismiss. Pair `<Dropdown.Toggle>` with `<Dropdown.Menu>` / `<Dropdown.Item>`.

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

`<Dropdown>` is the menu-button compound: a trigger (`Dropdown.Toggle`),
a panel (`Dropdown.Menu`), and items (`Dropdown.Item`). It owns
keyboard navigation and click-outside dismiss so consumers stay
declarative.

## Keyboard

| Key                | Action                                            |
| ------------------ | ------------------------------------------------- |
| Space / Enter      | Toggle the menu (on Toggle) / activate (on Item). |
| Escape             | Close the menu and return focus to the Toggle.    |
| Outside mouse-down | Closes the menu.                                  |

## Accessibility

Follows the APG [menu button](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/)
pattern. The toggle sets `aria-haspopup="menu"`, `aria-expanded`, and
`aria-controls`; items use `role="menuitem"`. Active items expose
`aria-current="true"`.

## Example

```tsx
import { Dropdown } from './ui/dropdown/Dropdown';

<Dropdown>
  <Dropdown.Toggle>Sort</Dropdown.Toggle>
  <Dropdown.Menu>
    <Dropdown.Item>Most recent</Dropdown.Item>
    <Dropdown.Item active>Alphabetical</Dropdown.Item>
    <Dropdown.Item>Hardest first</Dropdown.Item>
  </Dropdown.Menu>
</Dropdown>
```

## Props

No component-specific props - accepts standard HTML attributes. See the TypeScript source below.

## Source: Dropdown.tsx

```tsx
import React, {
  createContext,
  forwardRef,
  useContext,
  useEffect,
  useId,
  useRef,
  useState
} from 'react';

interface DropdownContextShape {
  open: boolean;
  setOpen: (next: boolean) => void;
  toggleId: string;
  menuId: string;
}

const Ctx = createContext<DropdownContextShape | null>(null);

export interface DropdownProps extends React.HTMLAttributes<HTMLDivElement> {}

const DropdownRoot = ({ className = '', children, ...rest }: DropdownProps) => {
  const [open, setOpen] = useState(false);
  const rootRef = useRef<HTMLDivElement>(null);
  const toggleId = useId();
  const menuId = useId();

  useEffect(() => {
    if (!open) return;
    const onMouseDown = (e: MouseEvent) => {
      if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
        setOpen(false);
      }
    };
    const onKey = (e: KeyboardEvent) => {
      if (e.key === 'Escape') setOpen(false);
    };
    document.addEventListener('mousedown', onMouseDown);
    document.addEventListener('keydown', onKey);
    return () => {
      document.removeEventListener('mousedown', onMouseDown);
      document.removeEventListener('keydown', onKey);
    };
  }, [open]);

  const classes = ['dropdown', className].filter(Boolean).join(' ');
  return (
    <Ctx.Provider value={{ open, setOpen, toggleId, menuId }}>
      <div ref={rootRef} className={classes} {...rest}>
        {children}
      </div>
    </Ctx.Provider>
  );
};
DropdownRoot.displayName = 'Dropdown';

interface DropdownToggleProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {}

const DropdownToggle = forwardRef<HTMLButtonElement, DropdownToggleProps>(
  ({ className = '', onClick, children, ...rest }, ref) => {
    const ctx = useContext(Ctx);
    if (!ctx) throw new Error('Dropdown.Toggle must be used inside <Dropdown>');
    return (
      <button
        ref={ref}
        type='button'
        id={ctx.toggleId}
        className={['btn', className].filter(Boolean).join(' ')}
        aria-haspopup='menu'
        aria-expanded={ctx.open}
        aria-controls={ctx.menuId}
        onClick={e => {
          ctx.setOpen(!ctx.open);
          onClick?.(e);
        }}
        {...rest}
      >
        {children}
      </button>
    );
  }
);
DropdownToggle.displayName = 'Dropdown.Toggle';

const DropdownMenu = forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className = '', children, ...rest }, ref) => {
  const ctx = useContext(Ctx);
  if (!ctx) throw new Error('Dropdown.Menu must be used inside <Dropdown>');
  if (!ctx.open) return null;
  return (
    <div
      ref={ref}
      id={ctx.menuId}
      role='menu'
      aria-labelledby={ctx.toggleId}
      className={['dropdown__menu', className].filter(Boolean).join(' ')}
      {...rest}
    >
      {children}
    </div>
  );
});
DropdownMenu.displayName = 'Dropdown.Menu';

export interface DropdownItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
  active?: boolean;
  as?: 'a' | 'button';
  onSelect?: () => void;
}

const DropdownItem = forwardRef<
  HTMLAnchorElement | HTMLButtonElement,
  DropdownItemProps
>(
  (
    { active, as = 'a', className = '', onClick, onSelect, children, ...rest },
    ref
  ) => {
    const ctx = useContext(Ctx);
    const classes = ['dropdown__item', className].filter(Boolean).join(' ');
    const handleActivate = (e: React.SyntheticEvent) => {
      (onClick as ((e: React.SyntheticEvent) => void) | undefined)?.(e);
      onSelect?.();
      ctx?.setOpen(false);
    };
    if (as === 'button') {
      const { href: _href, ...buttonRest } =
        rest as React.AnchorHTMLAttributes<HTMLAnchorElement>;
      return (
        <button
          ref={ref as React.Ref<HTMLButtonElement>}
          type='button'
          role='menuitem'
          className={classes}
          aria-current={active ? 'true' : undefined}
          onClick={handleActivate as React.MouseEventHandler<HTMLButtonElement>}
          {...(buttonRest as React.ButtonHTMLAttributes<HTMLButtonElement>)}
        >
          {children}
        </button>
      );
    }
    return (
      <a
        ref={ref as React.Ref<HTMLAnchorElement>}
        role='menuitem'
        className={classes}
        aria-current={active ? 'true' : undefined}
        onClick={handleActivate as React.MouseEventHandler<HTMLAnchorElement>}
        {...rest}
      >
        {children}
      </a>
    );
  }
);
DropdownItem.displayName = 'Dropdown.Item';

export const Dropdown = Object.assign(DropdownRoot, {
  Toggle: DropdownToggle,
  Menu: DropdownMenu,
  Item: DropdownItem
});
```

## Source: dropdown.css

```css
.dropdown {
  position: relative;
  display: inline-block;
}
.dropdown__toggle {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 12px;
  border: var(--border-width-thick) solid var(--background-quaternary);
  background: var(--background-tertiary);
  color: var(--foreground-secondary);
  font: 400 var(--fs-md)/1 var(--font-sans);
  cursor: pointer;
}
.dropdown__toggle:hover {
  background: var(--foreground-secondary);
  color: var(--background-primary);
}
.dropdown__menu {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  min-width: 180px;
  background: var(--background-secondary);
  border: 1px solid var(--background-quaternary);
  padding: 4px 0;
  margin: 0;
  list-style: none;
  z-index: 50;
}
.dropdown__item {
  display: block;
  width: 100%;
  padding: 6px 14px;
  background: transparent;
  border: 0;
  text-align: left;
  color: var(--foreground-secondary);
  font: 400 var(--fs-md)/1.4 var(--font-sans);
  cursor: pointer;
  text-decoration: none;
}
.dropdown__item:hover,
.dropdown__item[aria-current='true'],
.dropdown__item--active {
  background: var(--background-tertiary);
  color: var(--foreground-primary);
}
```

## HTML / vanilla variant

```html
<div class="dropdown">
  <button class="btn" aria-expanded="true">Sort</button>
  <div class="dropdown__menu">
    <a class="dropdown__item" href="#">Most recent</a>
    <a class="dropdown__item dropdown__item--active" href="#">Alphabetical</a>
  </div>
</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.
