---
title: Dropdown Menu
description: A menu of actions or selectable items opened from a trigger.
---

<PackageInfo name="dropdown-menu" />

**bun**

```bash
bun add @data-slot/dropdown-menu
```

**npm**

```bash
npm install @data-slot/dropdown-menu
```

**pnpm**

```bash
pnpm add @data-slot/dropdown-menu
```

## Anatomy

Each item’s `data-value` is what arrives in the select event. `data-variant` is a styling hook only — it changes nothing about behavior.

```html
<div data-slot="dropdown-menu">
  <button data-slot="dropdown-menu-trigger">Actions</button>
  <div data-slot="dropdown-menu-content">
    <button data-slot="dropdown-menu-item" data-value="edit">Edit</button>
    <button data-slot="dropdown-menu-item" data-value="copy">Copy</button>
    <button data-slot="dropdown-menu-item" data-variant="destructive" data-value="delete">
      Delete
    </button>
  </div>
</div>
```

## Examples

### A menu of actions

<Example name="dropdown-menu" />

### Align with the trigger’s edge

`data-align="end"` lines the menu up with the trailing edge of its trigger.

<Example name="dropdown-menu" variant="extra" />

## API reference

### Initialization

#### `create(scope?)`

Find and bind uninitialized `[data-slot="dropdown-menu"]` descendants of `scope` (defaults to `document`). Returns `DropdownMenuController[]` for newly bound roots. To initialize the scope element itself, use `createDropdownMenu`.

```typescript
import { create } from "@data-slot/dropdown-menu";

const controllers = create();
```

#### `createDropdownMenu(root, options?)`

Create a `DropdownMenuController` for one root element. JavaScript options take precedence over the corresponding data attributes. Calling this again for a bound root returns its existing controller; destroy it before rebinding with new options.

```typescript
import { createDropdownMenu } from "@data-slot/dropdown-menu";

const controller = createDropdownMenu(element, {});
```

### Slots

#### Runtime Slots

- `dropdown-menu` - Root container.
- `dropdown-menu-trigger` - Required button that toggles the menu and anchors its position.
- `dropdown-menu-content` - Required menu panel containing action and selection items.
- `dropdown-menu-item` - Action item with no owned selection state.
- `dropdown-menu-radio-item` - Single-select menu item.
- `dropdown-menu-checkbox-item` - Multi-select menu item.
- `dropdown-menu-positioner` - Optional authored positioning wrapper.
- `dropdown-menu-portal` - Optional authored portal wrapper that contains `dropdown-menu-positioner`.

#### Style-only Slots

- `dropdown-menu-group` - Groups related items.
- `dropdown-menu-label` - Non-interactive label.
- `dropdown-menu-separator` - Visual divider.
- `dropdown-menu-shortcut` - Optional keyboard shortcut hint; displaying it does not register a keyboard shortcut.

#### Composed Portal Markup

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

### Data Attributes

#### Root and Content

| Attribute | Target | Description |
|-----------|--------|-------------|
| `data-state="open\|closed"` | root, content | Current open state |
| `data-open` / `data-closed` | root, content | Presence aliases for state styling |
| `data-value="..."` | root | Current committed radio value only |
| `data-side` | content, positioner | Computed side after collision handling |
| `data-align` | content, positioner | Computed alignment after collision handling |

#### Items

| Attribute | Target | Description |
|-----------|--------|-------------|
| `data-highlighted` | item | Current highlighted item |
| `data-checked` | radio, checkbox items | Current committed checked state |
| `data-disabled` | item | Disabled item |
| `data-variant` | item | Styling hook such as `destructive` |
| `data-inset` | item | Styling hook for left padding |

#### Defaults and Options

| Attribute | Target | Description |
|-----------|--------|-------------|
| `data-default-open` | root | Initial open state |
| `data-default-value` | root | Initial radio value |
| `data-default-values='["a","b"]'` | root | Initial checkbox values as a JSON array string |
| `data-default-checked` | radio, checkbox item | Item-level default checked state |
| `data-close-on-click-outside` | root | Close on outside interaction |
| `data-close-on-escape` | root | Close on Escape |
| `data-close-on-select` | root | Close after accepted activation |
| `data-highlight-item-on-hover` | root | Highlight and focus items on hover |

Default precedence is:

1. JavaScript options
2. Root data attributes
3. Item `data-default-checked`
4. Empty state

For radio items, root defaults win over item defaults. For checkbox items, root `data-default-values` wins over item `data-default-checked`.

### Options

| Option | Data Attribute | Type | Default | Description |
| --- | --- | --- | --- | --- |
| `defaultOpen` | `data-default-open` | `boolean` | `false` | Initial open state |
| `defaultValue` | `data-default-value` | `string \| null` | Item defaults, then `null` | Initial radio value; explicit null clears item defaults |
| `defaultValues` | `data-default-values` | `string[]` | Item defaults, then `[]` | Initial checkbox values; encode the attribute as a JSON array |
| `closeOnClickOutside` | `data-close-on-click-outside` | `boolean` | `true` | Close on outside interaction |
| `closeOnEscape` | `data-close-on-escape` | `boolean` | `true` | Close on Escape |
| `closeOnSelect` | `data-close-on-select` | `boolean` | `true` | Close after accepted user activation |
| `highlightItemOnHover` | `data-highlight-item-on-hover` | `boolean` | `true` | Highlight and focus items on hover |
| `side` | `data-side` | `"top" \| "right" \| "bottom" \| "left"` | `"bottom"` | Preferred popup side |
| `align` | `data-align` | `"start" \| "center" \| "end"` | `"start"` | Preferred popup alignment |
| `sideOffset` | `data-side-offset` | `number` | `4` | Distance from trigger (px) |
| `alignOffset` | `data-align-offset` | `number` | `0` | Alignment offset (px) |
| `avoidCollisions` | `data-avoid-collisions` | `boolean` | `true` | Flip and shift within the viewport |
| `collisionPadding` | `data-collision-padding` | `number` | `8` | Viewport edge padding (px) |
| `lockScroll` | `data-lock-scroll` | `boolean` | `true` | Lock page scroll while open; uses fixed positioning when enabled and absolute positioning when disabled |
| `onOpenChange` | — | `(open: boolean) => void` | `undefined` | Called when open state changes |
| `onSelect` | — | `(value: string) => void` | `undefined` | Called after accepted user activation |
| `onValueChange` | — | `(value: string \| null) => void` | `undefined` | Called when committed radio selection changes |
| `onValuesChange` | — | `(values: string[]) => void` | `undefined` | Called when committed checkbox selection changes |

Notes:

- `closeOnSelect` defaults to `true`. Multi-select menus usually want `false`.
- `onSelect` tracks accepted user activation. It does not fire for programmatic state changes.
- `onValueChange` and `onValuesChange` follow the same silence rules as their DOM events.

### Controller

```ts
interface DropdownMenuController {
  open(): void;
  close(): void;
  toggle(): void;
  set(detail: DropdownMenuSetDetail): void;
  readonly isOpen: boolean;
  readonly value: string | null;
  readonly values: string[];
  readonly highlightedValue: string | null;
  destroy(): void;
}
```

`DropdownMenuSetDetail` is a partial update:

```ts
interface DropdownMenuSetDetail {
  open?: boolean;
  value?: string | null;
  values?: string[];
  highlightedValue?: string | null;
  source?: "programmatic" | "restore";
}
```

Use `value: null` to clear radio selection, `values: []` to clear checkbox selection, and `highlightedValue: null` to clear the highlight. `source` defaults to `"programmatic"`.

`set()` applies fields in this order: `value`, `values`, `open`, `highlightedValue`.

- `set({ value })` commits radio selection.
- `set({ values })` commits checkbox selection.
- `set({ open })` opens or closes the menu.
- `set({ highlightedValue })` updates highlight only while the menu is open.
- Programmatic `set()` never emits `dropdown-menu:select`.
- No-op updates are silent.
- Unknown `value` / `values` targets are ignored, not thrown.

#### Downstream Wrapper Contract

This package does not ship Astro components, but downstream wrappers should mirror this authoring model:

- `DropdownMenuItem` renders `data-slot="dropdown-menu-item"`.
- `DropdownMenuRadioItem` renders `data-slot="dropdown-menu-radio-item"` and requires `value`.
- `DropdownMenuCheckboxItem` renders `data-slot="dropdown-menu-checkbox-item"` and requires `value`.
- Radio and checkbox wrappers may expose `defaultChecked`, but root defaults still take precedence over item defaults.

#### Controller Destruction

`destroy()` permanently disposes the controller and hides any open surface without
emitting an additional change event. Repeated destruction is safe; methods on the
old controller become no-ops. Create a new controller on the same root to rebind it.

Focus restoration already queued by a close survives destruction.

### Events

#### Outbound Events

| Event | Detail | Notes |
|-------|--------|-------|
| `dropdown-menu:open-change` | `{ open, previousOpen, source, reason }` | Fires on real open-state changes |
| `dropdown-menu:change` | same detail | Deprecated alias for `open-change`; only `detail.open` is compatibility-guaranteed |
| `dropdown-menu:highlight-change` | `{ value, previousValue, item, previousItem, source }` | `value` and `item` become `null` when highlight clears |
| `dropdown-menu:select` | `{ value, item, itemType, source, checked? }` | Cancelable, user-only, fires before commit |
| `dropdown-menu:value-change` | `{ value, previousValue, item, previousItem, source }` | Radio commits only |
| `dropdown-menu:values-change` | `{ values, previousValues, changedValue, checked, item, source }` | Checkbox commits only |

`dropdown-menu:select` behavior:

- Fires only for user activation attempts. Disabled items and programmatic updates do not emit it.
- If `event.preventDefault()` is called, no selection state changes and no auto-close from selection occur.
- Programmatic updates never emit it.

`dropdown-menu:value-change`, `dropdown-menu:values-change`, `onValueChange`, and `onValuesChange` are silent on:

- initialization
- no-op commits
- ignored unknown targets

For `dropdown-menu:values-change`, `changedValue`, `checked`, and `item` are `null` when one programmatic update changes more than one checkbox at once.

#### Inbound Events

| Event | Detail | Description |
|-------|--------|-------------|
| `dropdown-menu:set` | `DropdownMenuSetDetail` | Partial programmatic state update |

```js
root.dispatchEvent(
  new CustomEvent("dropdown-menu:set", {
    detail: { value: "pro", source: "restore" },
  })
);

root.dispatchEvent(
  new CustomEvent("dropdown-menu:set", {
    detail: { values: ["email", "push"], open: true },
  })
);
```

#### Event Order

User radio selection:

1. `dropdown-menu:select`
2. `dropdown-menu:value-change`
3. `dropdown-menu:open-change` if the menu closes

User checkbox selection:

1. `dropdown-menu:select`
2. `dropdown-menu:values-change`
3. `dropdown-menu:open-change` if the menu closes

Programmatic selection:

1. `dropdown-menu:set` or `controller.set(...)`
2. `dropdown-menu:value-change` or `dropdown-menu:values-change`
3. No `dropdown-menu:select`

If closing the menu also clears an existing highlight, `dropdown-menu:highlight-change` is emitted before the close-side `dropdown-menu:open-change`.

### Styling

#### Positioning

Placement attributes (`data-side`, `data-align`, `data-side-offset`, `data-align-offset`, `data-avoid-collisions`, `data-collision-padding`) resolve in this order:

1. JavaScript option
2. `dropdown-menu-content`
3. `dropdown-menu-positioner`
4. `dropdown-menu` root

The dropdown menu uses `position: fixed` by default and automatically positions itself relative to the trigger:

```js
createDropdownMenu(root, {
  side: "bottom",
  align: "start",
  sideOffset: 4,
  alignOffset: 0,
  avoidCollisions: true,
  collisionPadding: 8,
});
```

When `avoidCollisions` is enabled, the menu may flip sides or shift within the viewport. The positioned element also receives `--transform-origin` for animation origins.

### Keyboard Navigation

| Key | Action |
|-----|--------|
| `Enter` / `Space` | Open menu from trigger, or activate highlighted item |
| `ArrowDown` | Open menu from trigger, or move to next enabled item |
| `ArrowUp` | Move to previous enabled item |
| `Home` | Move to first enabled item |
| `End` | Move to last enabled item |
| `Escape` | Close menu |
| `Tab` | Close menu and continue tab order |
| `A-Z` | Typeahead by item text |

### Migration Notes

If you currently use dropdown-menu as a picker:

- Replace picker-style `dropdown-menu-item` usage with `dropdown-menu-radio-item` or `dropdown-menu-checkbox-item`.
- Stop manually writing `data-selected` or `data-checked`.
- Listen to `dropdown-menu:value-change` or `dropdown-menu:values-change` for committed state.
- Push external restore/randomize/popstate/storage changes back in through `controller.set(...)` or `dropdown-menu:set`.
- Switch open-state listeners from `dropdown-menu:change` to `dropdown-menu:open-change`.

#### Deprecated APIs

The following compatibility APIs are deprecated and will be removed in the next major release:

```js
// Deprecated open-state alias
root.addEventListener("dropdown-menu:change", (event) => {
  console.log(event.detail.open);
});

// Deprecated programmatic shape
root.dispatchEvent(
  new CustomEvent("dropdown-menu:set", { detail: { value: true } })
);
```

Use `dropdown-menu:open-change` and `dropdown-menu:set { open: boolean }` instead.
