Skip to content
data-slotv1.0.0
Esc
↑↓navigate↵open⌘Jpreview
On this page

Dropdown Menu

A menu of actions or selectable items opened from a trigger.

@data-slot/dropdown-menuSource ↗
bun add @data-slot/dropdown-menu
npm install @data-slot/dropdown-menu
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.

<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

Preview
Show codeHide code
<div data-slot="dropdown-menu" data-align="center" class="dropdown-root">
  <button data-slot="dropdown-menu-trigger" class="dropdown-trigger">
    Actions ▼
  </button>
  <div data-slot="dropdown-menu-content" class="dropdown-content" hidden>
    <div data-slot="dropdown-menu-group">
      <div data-slot="dropdown-menu-label" class="dropdown-label">Edit</div>
      <button data-slot="dropdown-menu-item" data-value="cut" class="dropdown-item">
        Cut
        <span data-slot="dropdown-menu-shortcut" class="dropdown-shortcut">⌘X</span>
      </button>
      <button data-slot="dropdown-menu-item" data-value="copy" class="dropdown-item">
        Copy
        <span data-slot="dropdown-menu-shortcut" class="dropdown-shortcut">⌘C</span>
      </button>
      <button data-slot="dropdown-menu-item" data-value="paste" class="dropdown-item">
        Paste
        <span data-slot="dropdown-menu-shortcut" class="dropdown-shortcut">⌘V</span>
      </button>
    </div>
    <div data-slot="dropdown-menu-separator" class="dropdown-separator"></div>
    <button data-slot="dropdown-menu-item" data-value="delete" data-variant="destructive" class="dropdown-item destructive">
      Delete
    </button>
    <button data-slot="dropdown-menu-item" data-disabled class="dropdown-item disabled">
      Archive (coming soon)
    </button>
  </div>
</div>

<style>
  .dropdown-root { position: relative; display: inline-block; }
  .dropdown-trigger {
    padding: 0.5rem 1rem;
    background: var(--surface);
    color: var(--text);
    border: 1px solid var(--border);
    cursor: pointer;
    transition: opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1);
  }
  .dropdown-trigger:hover { opacity: 0.9; }
  .dropdown-content {
    position: absolute;
    top: 100%;
    left: 0;
    margin-top: 0.25rem;
    background: var(--surface);
    border: 1px solid var(--border);
    min-width: 12rem;
    padding: 0.25rem;
    z-index: 50;
    box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
    width: 14rem;
  }
  .dropdown-label {
    padding: 0.375rem 0.5rem;
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--muted);
    line-height: 1rem;
  }
  .dropdown-item {
    display: flex;
    align-items: center;
    width: 100%;
    padding: 0.375rem 0.5rem;
    border: none;
    background: none;
    cursor: pointer;
    font-size: 0.875rem;
    text-align: left;
    line-height: 1.25rem;
  }
  .dropdown-item[data-highlighted] {
    background: var(--code-bg);
  }
  .dropdown-item.destructive { color: var(--danger); }
  .dropdown-item.destructive[data-highlighted] {
    background: var(--danger-bg);
  }
  .dropdown-item.disabled {
    color: var(--border);
    cursor: not-allowed;
  }
  .dropdown-shortcut {
    margin-left: auto;
    font-size: 0.75rem;
    color: var(--muted);
    line-height: 1rem;
  }
  .dropdown-separator {
    height: 1px;
    background: var(--border);
    margin: 0.25rem 0;
  }
</style>
<div data-slot="dropdown-menu" class="relative inline-block">
  <button
    data-slot="dropdown-menu-trigger"
    class="px-4 py-2 bg-[var(--surface)] text-text border border-[var(--border)] cursor-pointer
           hover:opacity-90 transition-opacity"
  >
    Actions ▼
  </button>
  <div
    data-slot="dropdown-menu-content"
    class="absolute top-full left-0 bg-[var(--surface)] border border-[var(--border)]
           w-56 p-1 z-50 shadow-lg"
    hidden
  >
    <div data-slot="dropdown-menu-group">
      <div
        data-slot="dropdown-menu-label"
        class="px-2 py-1.5 text-xs font-semibold text-[var(--muted)]"
      >
        Edit
      </div>
      <button
        data-slot="dropdown-menu-item"
        data-value="cut"
        class="flex items-center w-full px-2 py-1.5 text-sm text-left
               data-highlighted:bg-[var(--code-bg)]"
      >
        Cut
        <span data-slot="dropdown-menu-shortcut" class="ml-auto text-xs text-[var(--muted)]">⌘X</span>
      </button>
      <button
        data-slot="dropdown-menu-item"
        data-value="copy"
        class="flex items-center w-full px-2 py-1.5 text-sm text-left
               data-highlighted:bg-[var(--code-bg)]"
      >
        Copy
        <span data-slot="dropdown-menu-shortcut" class="ml-auto text-xs text-[var(--muted)]">⌘C</span>
      </button>
      <button
        data-slot="dropdown-menu-item"
        data-value="paste"
        class="flex items-center w-full px-2 py-1.5 text-sm text-left
               data-highlighted:bg-[var(--code-bg)]"
      >
        Paste
        <span data-slot="dropdown-menu-shortcut" class="ml-auto text-xs text-[var(--muted)]">⌘V</span>
      </button>
    </div>
    <div data-slot="dropdown-menu-separator" class="h-px bg-[var(--border)] my-1"></div>
    <button
      data-slot="dropdown-menu-item"
      data-value="delete"
      data-variant="destructive"
      class="flex items-center w-full px-2 py-1.5 text-sm text-left
             text-[var(--danger)] data-highlighted:bg-[var(--danger-bg)]"
    >
      Delete
    </button>
    <button
      data-slot="dropdown-menu-item"
      data-disabled
      class="flex items-center w-full px-2 py-1.5 text-sm text-left
             text-[var(--border)] cursor-not-allowed"
    >
      Archive (coming soon)
    </button>
  </div>
</div>

Align with the trigger’s edge

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

Preview
Show codeHide code
<div data-slot="dropdown-menu" data-align="end" class="dropdown-root">
  <button data-slot="dropdown-menu-trigger" class="dropdown-trigger">
    Actions ▼
  </button>
  <div data-slot="dropdown-menu-content" class="dropdown-content" hidden>
    <div data-slot="dropdown-menu-group">
      <div data-slot="dropdown-menu-label" class="dropdown-label">Edit</div>
      <button data-slot="dropdown-menu-item" data-value="cut" class="dropdown-item">
        Cut
        <span data-slot="dropdown-menu-shortcut" class="dropdown-shortcut">⌘X</span>
      </button>
      <button data-slot="dropdown-menu-item" data-value="copy" class="dropdown-item">
        Copy
        <span data-slot="dropdown-menu-shortcut" class="dropdown-shortcut">⌘C</span>
      </button>
      <button data-slot="dropdown-menu-item" data-value="paste" class="dropdown-item">
        Paste
        <span data-slot="dropdown-menu-shortcut" class="dropdown-shortcut">⌘V</span>
      </button>
    </div>
    <div data-slot="dropdown-menu-separator" class="dropdown-separator"></div>
    <button data-slot="dropdown-menu-item" data-value="delete" data-variant="destructive" class="dropdown-item destructive">
      Delete
    </button>
    <button data-slot="dropdown-menu-item" data-disabled class="dropdown-item disabled">
      Archive (coming soon)
    </button>
  </div>
</div>

<style>
  .dropdown-root { position: relative; display: inline-block; }
  .dropdown-trigger {
    padding: 0.5rem 1rem;
    background: var(--surface);
    color: var(--text);
    border: 1px solid var(--border);
    cursor: pointer;
    transition: opacity 0.15s cubic-bezier(0.4, 0, 0.2, 1);
  }
  .dropdown-trigger:hover { opacity: 0.9; }
  .dropdown-content {
    position: absolute;
    top: 100%;
    left: 0;
    margin-top: 0.25rem;
    background: var(--surface);
    border: 1px solid var(--border);
    min-width: 12rem;
    padding: 0.25rem;
    z-index: 50;
    box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
    width: 14rem;
  }
  .dropdown-label {
    padding: 0.375rem 0.5rem;
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--muted);
    line-height: 1rem;
  }
  .dropdown-item {
    display: flex;
    align-items: center;
    width: 100%;
    padding: 0.375rem 0.5rem;
    border: none;
    background: none;
    cursor: pointer;
    font-size: 0.875rem;
    text-align: left;
    line-height: 1.25rem;
  }
  .dropdown-item[data-highlighted] {
    background: var(--code-bg);
  }
  .dropdown-item.destructive { color: var(--danger); }
  .dropdown-item.destructive[data-highlighted] {
    background: var(--danger-bg);
  }
  .dropdown-item.disabled {
    color: var(--border);
    cursor: not-allowed;
  }
  .dropdown-shortcut {
    margin-left: auto;
    font-size: 0.75rem;
    color: var(--muted);
    line-height: 1rem;
  }
  .dropdown-separator {
    height: 1px;
    background: var(--border);
    margin: 0.25rem 0;
  }
</style>
<div data-slot="dropdown-menu" data-align="end" class="relative inline-block">
  <button
    data-slot="dropdown-menu-trigger"
    class="px-4 py-2 bg-[var(--surface)] text-text border border-[var(--border)] cursor-pointer
           hover:opacity-90 transition-opacity"
  >
    Actions ▼
  </button>
  <div
    data-slot="dropdown-menu-content"
    class="absolute top-full left-0 bg-[var(--surface)] border border-[var(--border)]
           w-56 p-1 z-50 shadow-lg"
    hidden
  >
    <div data-slot="dropdown-menu-group">
      <div
        data-slot="dropdown-menu-label"
        class="px-2 py-1.5 text-xs font-semibold text-[var(--muted)]"
      >
        Edit
      </div>
      <button
        data-slot="dropdown-menu-item"
        data-value="cut"
        class="flex items-center w-full px-2 py-1.5 text-sm text-left
               data-highlighted:bg-[var(--code-bg)]"
      >
        Cut
        <span data-slot="dropdown-menu-shortcut" class="ml-auto text-xs text-[var(--muted)]">⌘X</span>
      </button>
      <button
        data-slot="dropdown-menu-item"
        data-value="copy"
        class="flex items-center w-full px-2 py-1.5 text-sm text-left
               data-highlighted:bg-[var(--code-bg)]"
      >
        Copy
        <span data-slot="dropdown-menu-shortcut" class="ml-auto text-xs text-[var(--muted)]">⌘C</span>
      </button>
      <button
        data-slot="dropdown-menu-item"
        data-value="paste"
        class="flex items-center w-full px-2 py-1.5 text-sm text-left
               data-highlighted:bg-[var(--code-bg)]"
      >
        Paste
        <span data-slot="dropdown-menu-shortcut" class="ml-auto text-xs text-[var(--muted)]">⌘V</span>
      </button>
    </div>
    <div data-slot="dropdown-menu-separator" class="h-px bg-[var(--border)] my-1"></div>
    <button
      data-slot="dropdown-menu-item"
      data-value="delete"
      data-variant="destructive"
      class="flex items-center w-full px-2 py-1.5 text-sm text-left
             text-[var(--danger)] data-highlighted:bg-[var(--danger-bg)]"
    >
      Delete
    </button>
    <button
      data-slot="dropdown-menu-item"
      data-disabled
      class="flex items-center w-full px-2 py-1.5 text-sm text-left
             text-[var(--border)] cursor-not-allowed"
    >
      Archive (coming soon)
    </button>
  </div>
</div>

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.

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.

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

<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

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:

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
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:

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:

// 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.