---
title: DropdownMenu Menu
description: "A primitive for building dropdown menus with keyboard navigation, typeahead, radio groups, and checkbox items."
order: 9
tags: [primitives]
---

# DropdownMenu Menu

A primitive for building dropdown menus with full keyboard navigation, typeahead matching, radio groups, checkbox items, and Radix-compatible positioning.

```ts
import { DropdownMenu } from "@areia/slots";

document.querySelector("#root")!.innerHTML = `
  <div data-slot="dropdown-menu">
    <button data-slot="dropdown-menu-trigger" class="win95-button">Options</button>
    <div data-slot="dropdown-menu-content" class="win95-menu">
      <div data-slot="dropdown-menu-group">
        <div data-slot="dropdown-menu-label" class="win95-label">Actions</div>
        <button data-slot="dropdown-menu-item" data-value="edit" class="win95-menu-item">
          Edit
        </button>
        <button data-slot="dropdown-menu-item" data-value="copy" class="win95-menu-item">
          <span>Copy</span>
          <span data-slot="dropdown-menu-shortcut" class="float-right ml-8 text-neutral-700">⌘C</span>
        </button>
      </div>
      <div data-slot="dropdown-menu-separator" class="win95-separator"></div>
      <button
        data-slot="dropdown-menu-item"
        data-value="delete"
        data-variant="destructive"
        class="win95-menu-item"
      >
        Delete
      </button>
      <button
        data-slot="dropdown-menu-item"
        data-value="disabled"
        data-disabled
        class="win95-menu-item"
      >
        Disabled item
      </button>
    </div>
  </div>
`;

const root = document.querySelector<HTMLElement>(
  '[data-slot="dropdown-menu"]',
)!;
if (root) {
  const controller = DropdownMenu.createDropdownMenu(root, {
    onSelect: (value) => console.log("selected", value),
    onValueChange: (value) =>
      console.log("value changed", value),
  });
}
```

## Import

```ts
import { DropdownMenu } from "@areia/slots";
```

## Usage

Call `createDropdownMenu` with the root element and optional configuration. The primitive reads slot attributes from the DOM and manages open/close, item highlight, typeahead, keyboard navigation, radio and checkbox selection, and positioning.

```ts
import { DropdownMenu } from "@areia/slots";

const el = document.querySelector<HTMLElement>(
  '[data-slot="dropdown-menu"]',
)!;
if (!el) throw new Error("No root element found");

const controller = DropdownMenu.createDropdownMenu(el, {
  side: "bottom",
  align: "start",
  sideOffset: 4,
  closeOnSelect: true,
  onSelect: (value) => console.log("selected", value),
  onValueChange: (value) => console.log("radio value", value),
  onValuesChange: (values) =>
    console.log("checkbox values", values),
});

// Programmatic control
controller.open();
// controller.close();
// controller.set({ value: "option-b" });
// controller.destroy();
```

### Auto-bind

Use the `create` helper to discover and bind every unattached root in a scope.

```ts
import { DropdownMenu } from "@areia/slots";

const controllers = DropdownMenu.create(document);
// controllers.forEach((c) => { ... });
```

## Expected Markup

The primitive expects the following slot structure.

```html
<div data-slot="dropdown-menu">
  <button data-slot="dropdown-menu-trigger">Options</button>

  <div data-slot="dropdown-menu-content">
    <div data-slot="dropdown-menu-group">
      <div data-slot="dropdown-menu-label">Actions</div>

      <button data-slot="dropdown-menu-item" data-value="edit">
        Edit
      </button>

      <button data-slot="dropdown-menu-item" data-value="copy">
        <span>Copy</span>
        <span data-slot="dropdown-menu-shortcut">⌘C</span>
      </button>
    </div>

    <div data-slot="dropdown-menu-separator"></div>

    <button
      data-slot="dropdown-menu-item"
      data-value="delete"
      data-variant="destructive"
    >
      Delete
    </button>

    <button
      data-slot="dropdown-menu-item"
      data-value="disabled"
      data-disabled
    >
      Disabled item
    </button>

    <div data-slot="dropdown-menu-separator"></div>

    <button
      data-slot="dropdown-menu-checkbox-item"
      data-value="show-sidebar"
    >
      Show sidebar
    </button>

    <button
      data-slot="dropdown-menu-radio-item"
      data-value="option-a"
    >
      Option A
    </button>
    <button
      data-slot="dropdown-menu-radio-item"
      data-value="option-b"
    >
      Option B
    </button>
  </div>
</div>
```

### Slot Reference

| Slot                          | Purpose                                             |
| ----------------------------- | --------------------------------------------------- |
| `dropdown-menu`               | Root element. Controller is bound here.             |
| `dropdown-menu-trigger`       | Button that toggles the menu.                       |
| `dropdown-menu-content`       | Popup container positioned relative to the trigger. |
| `dropdown-menu-item`          | Selectable menu action.                             |
| `dropdown-menu-label`         | Non-interactive label for a group of items.         |
| `dropdown-menu-group`         | Wrapper that groups items under a label.            |
| `dropdown-menu-separator`     | Visual divider between item groups.                 |
| `dropdown-menu-shortcut`      | Keyboard shortcut hint displayed alongside an item. |
| `dropdown-menu-checkbox-item` | Toggleable item that emits checked state changes.   |
| `dropdown-menu-radio-item`    | Mutually exclusive selection item within the menu.  |

### Data Attributes

| Attribute                    | Applies to                                  | Description                                           |
| ---------------------------- | ------------------------------------------- | ----------------------------------------------------- |
| `data-value`                 | `*-item`, `*-checkbox-item`, `*-radio-item` | Value emitted on selection or state change.           |
| `data-disabled`              | `*-item`, `*-checkbox-item`, `*-radio-item` | Prevents selection, highlight, and hover interaction. |
| `data-variant="destructive"` | `*-item`                                    | Marks the item as a destructive action.               |
| `data-type`                  | `*-item`                                    | Custom type annotation attached to the element.       |

The controller manages these state attributes on the root:

| Attribute     | Values                | Description                      |
| ------------- | --------------------- | -------------------------------- |
| `data-state`  | `"open"` / `"closed"` | Current open state.              |
| `data-open`   | (present)             | Present when the menu is open.   |
| `data-closed` | (present)             | Present when the menu is closed. |

Items receive the following state attributes:

| Attribute          | Description                                |
| ------------------ | ------------------------------------------ |
| `data-highlighted` | Present on the currently highlighted item. |
| `data-checked`     | Present on a checked checkbox/radio item.  |

## Events

All events bubble from the root element (`data-slot="dropdown-menu"`).

### Outbound

| Event                            | Detail                                                            | Description                                                              |
| -------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `dropdown-menu:open-change`      | `{ open, previousOpen, source, reason }`                          | Fires when the menu opens or closes.                                     |
| `dropdown-menu:highlight-change` | `{ value, previousValue, item, previousItem, source }`            | Fires when the highlighted item changes.                                 |
| `dropdown-menu:select`           | `{ value, item, itemType, source, checked? }`                     | Cancelable. Fires when a user selects an item, before the value commits. |
| `dropdown-menu:value-change`     | `{ value, previousValue, item, previousItem, source }`            | Fires when the committed radio value changes.                            |
| `dropdown-menu:values-change`    | `{ values, previousValues, changedValue, checked, item, source }` | Fires when the committed checkbox values change.                         |

### Inbound

| Event               | Detail                                                   | Description                                          |
| ------------------- | -------------------------------------------------------- | ---------------------------------------------------- |
| `dropdown-menu:set` | `{ open?, value?, values?, highlightedValue?, source? }` | Set open/highlight/selection state programmatically. |

#### `dropdown-menu:open-change`

```ts
interface DropdownMenuOpenChangeDetail {
  open: boolean;
  previousOpen: boolean;
  source: "pointer" | "keyboard" | "programmatic" | "init";
  reason:
    | "trigger"
    | "select"
    | "outside"
    | "escape"
    | "tab"
    | "programmatic"
    | "init";
}
```

#### `dropdown-menu:select`

```ts
interface DropdownMenuSelectDetail {
  value: string;
  item: HTMLElement;
  itemType: "item" | "radio" | "checkbox";
  source: "pointer" | "keyboard";
  checked?: boolean;
}
```

This event is **cancelable**. Call `event.preventDefault()` to prevent the selection from being committed.

#### `dropdown-menu:value-change`

```ts
interface DropdownMenuValueChangeDetail {
  value: string | null;
  previousValue: string | null;
  item: HTMLElement | null;
  previousItem: HTMLElement | null;
  source: "pointer" | "keyboard" | "programmatic" | "restore";
}
```

#### `dropdown-menu:values-change`

```ts
interface DropdownMenuValuesChangeDetail {
  values: string[];
  previousValues: string[];
  changedValue: string | null;
  checked: boolean | null;
  item: HTMLElement | null;
  source: "pointer" | "keyboard" | "programmatic" | "restore";
}
```

#### `dropdown-menu:highlight-change`

```ts
interface DropdownMenuHighlightChangeDetail {
  value: string | null;
  previousValue: string | null;
  item: HTMLElement | null;
  previousItem: HTMLElement | null;
  source: "pointer" | "keyboard" | "programmatic" | "restore";
}
```

## API Reference

### Options

`DropdownMenu.createDropdownMenu(root, options)`

| Option                 | Type                                     | Default    | Description                                                          |
| ---------------------- | ---------------------------------------- | ---------- | -------------------------------------------------------------------- |
| `defaultOpen`          | `boolean`                                | `false`    | Initial open state.                                                  |
| `defaultValue`         | `string \| null`                         | —          | Initial radio selection value.                                       |
| `defaultValues`        | `string[]`                               | —          | Initial checkbox selection values.                                   |
| `onOpenChange`         | `(open: boolean) => void`                | —          | Called when open state changes.                                      |
| `onPortalMounted`      | `(container: HTMLElement) => void`       | —          | After portaled menu mounts on open.                                  |
| `onSelect`             | `(value: string) => void`                | —          | Called when a user selects an item.                                  |
| `onValueChange`        | `(value: string \| null) => void`        | —          | Called when the radio value changes.                                 |
| `onValuesChange`       | `(values: string[]) => void`             | —          | Called when checkbox values change.                                  |
| `closeOnClickOutside`  | `boolean`                                | `true`     | Close when clicking outside the menu.                                |
| `closeOnEscape`        | `boolean`                                | `true`     | Close when pressing Escape.                                          |
| `closeOnSelect`        | `boolean`                                | `true`     | Close when an item is selected.                                      |
| `side`                 | `"top" \| "right" \| "bottom" \| "left"` | `"bottom"` | Preferred side of the trigger to render against.                     |
| `align`                | `"start" \| "center" \| "end"`           | `"start"`  | Preferred alignment against the trigger.                             |
| `sideOffset`           | `number`                                 | `4`        | Distance in px from the trigger.                                     |
| `alignOffset`          | `number`                                 | `0`        | Offset in px from the alignment edge.                                |
| `avoidCollisions`      | `boolean`                                | `true`     | Override side/align to prevent viewport collisions.                  |
| `collisionPadding`     | `number`                                 | `8`        | Padding between content and viewport edges when avoiding collisions. |
| `lockScroll`           | `boolean`                                | `true`     | Lock body scroll when the menu is open.                              |
| `highlightItemOnHover` | `boolean`                                | `true`     | Whether moving the pointer over items highlights them.               |

Options can also be set via `data-*` attributes on the root element, using kebab-case names (e.g. `data-close-on-select="false"`). JS options take precedence over data attributes.

### Controller

| Member             | Type                                      | Description                                                    |
| ------------------ | ----------------------------------------- | -------------------------------------------------------------- |
| `isOpen`           | `boolean` (getter)                        | Current open state.                                            |
| `value`            | `string \| null` (getter)                 | Current committed radio selection value.                       |
| `values`           | `string[]` (getter)                       | Current committed checkbox selection values.                   |
| `highlightedValue` | `string \| null` (getter)                 | `data-value` of the currently highlighted item.                |
| `open()`           | `() => void`                              | Open the menu.                                                 |
| `close()`          | `() => void`                              | Close the menu.                                                |
| `toggle()`         | `() => void`                              | Toggle the menu open state.                                    |
| `set(detail)`      | `(detail: DropdownMenuSetDetail) => void` | Set open, value, values, or highlightedValue programmatically. |
| `destroy()`        | `() => void`                              | Remove all event listeners and clean up.                       |

## Controller

The controller is the imperative handle returned by `createDropdownMenu`. Use it for programmatic control and reading selection state.

```ts
import { DropdownMenu } from "@areia/slots";

const el = document.querySelector<HTMLElement>(
  '[data-slot="dropdown-menu"]',
)!;
if (!el) throw new Error("No root element");

const ctrl = DropdownMenu.createDropdownMenu(el);

// Read state
console.log(ctrl.isOpen); // boolean
console.log(ctrl.value); // string | null (radio)
console.log(ctrl.values); // string[] (checkbox)
console.log(ctrl.highlightedValue); // string | null

// Open / close
ctrl.open();
ctrl.close();
ctrl.toggle();

// Set state programmatically
ctrl.set({ open: true });
ctrl.set({ value: "option-a" });
ctrl.set({ values: ["show-sidebar", "autosave"] });
ctrl.set({
  open: false,
  value: null,
  values: [],
  highlightedValue: null,
  source: "programmatic",
});

// Tear down all listeners and clean up
ctrl.destroy();
```

### Keyboard Navigation

When the menu is open, the following keyboard interactions are supported:

| Key               | Behavior                                                                        |
| ----------------- | ------------------------------------------------------------------------------- |
| `ArrowDown`       | Move highlight to the next enabled item.                                        |
| `ArrowUp`         | Move highlight to the previous enabled item.                                    |
| `Home`            | Move highlight to the first enabled item.                                       |
| `End`             | Move highlight to the last enabled item.                                        |
| `Enter` / `Space` | Activate the highlighted item.                                                  |
| `Escape`          | Close the menu.                                                                 |
| `Tab`             | Close the menu.                                                                 |
| Printable keys    | Typeahead: jump to the next item whose text starts with the typed character(s). |

When `closeOnSelect` is `false`, selecting a radio or checkbox item toggles the checked state without closing the menu. When `true` (default), the menu closes after any item selection.
