@data-slot/dropdown-menu
v1.0.0
Published
Headless dropdown menu for vanilla JavaScript. Supports action items, single-select radio items, and multi-select checkbox items with full keyboard navigation and ARIA support.
Downloads
2,965
Maintainers
Readme
@data-slot/dropdown-menu
Headless dropdown menu for vanilla JavaScript. Supports action items, single-select radio items, and multi-select checkbox items with full keyboard navigation and ARIA support.
Installation
npm install @data-slot/dropdown-menuUsage
Action Menu
<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>Single-Select Menu
<div data-slot="dropdown-menu" data-default-value="pro">
<button data-slot="dropdown-menu-trigger">Plan</button>
<div data-slot="dropdown-menu-content">
<button data-slot="dropdown-menu-radio-item" data-value="starter">Starter</button>
<button data-slot="dropdown-menu-radio-item" data-value="pro">Pro</button>
<button data-slot="dropdown-menu-radio-item" data-value="team">Team</button>
</div>
</div>Multi-Select Menu
<div
data-slot="dropdown-menu"
data-close-on-select="false"
data-default-values='["email","push"]'
>
<button data-slot="dropdown-menu-trigger">Channels</button>
<div data-slot="dropdown-menu-content">
<button data-slot="dropdown-menu-checkbox-item" data-value="email">Email</button>
<button data-slot="dropdown-menu-checkbox-item" data-value="sms">SMS</button>
<button data-slot="dropdown-menu-checkbox-item" data-value="push">Push</button>
</div>
</div>JavaScript
import { create, createDropdownMenu } from "@data-slot/dropdown-menu";
const controllers = create();
const root = document.querySelector('[data-slot="dropdown-menu"]');
const controller = createDropdownMenu(root, {
onOpenChange: (open) => console.log("open:", open),
onSelect: (value) => console.log("activation:", value),
onValueChange: (value) => console.log("radio value:", value),
onValuesChange: (values) => console.log("checkbox values:", values),
});
controller.set({ value: "pro" });
controller.set({ values: ["email", "push"] });
controller.set({ open: true, highlightedValue: "push" });
controller.destroy();createDropdownMenu(root) is idempotent per root. Calling it again for the same element returns the existing controller.
API
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 containsdropdown-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:
- JavaScript options
- Root data attributes
- Item
data-default-checked - 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:
closeOnSelectdefaults totrue. Multi-select menus usually wantfalse.onSelecttracks accepted user activation. It does not fire for programmatic state changes.onValueChangeandonValuesChangefollow 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 emitsdropdown-menu:select. - No-op updates are silent.
- Unknown
value/valuestargets are ignored, not thrown.
Downstream Wrapper Contract
This package does not ship Astro components, but downstream wrappers should mirror this authoring model:
DropdownMenuItemrendersdata-slot="dropdown-menu-item".DropdownMenuRadioItemrendersdata-slot="dropdown-menu-radio-item"and requiresvalue.DropdownMenuCheckboxItemrendersdata-slot="dropdown-menu-checkbox-item"and requiresvalue.- 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:
dropdown-menu:selectdropdown-menu:value-changedropdown-menu:open-changeif the menu closes
User checkbox selection:
dropdown-menu:selectdropdown-menu:values-changedropdown-menu:open-changeif the menu closes
Programmatic selection:
dropdown-menu:setorcontroller.set(...)dropdown-menu:value-changeordropdown-menu:values-change- 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:
- JavaScript option
dropdown-menu-contentdropdown-menu-positionerdropdown-menuroot
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-itemusage withdropdown-menu-radio-itemordropdown-menu-checkbox-item. - Stop manually writing
data-selectedordata-checked. - Listen to
dropdown-menu:value-changeordropdown-menu:values-changefor committed state. - Push external restore/randomize/popstate/storage changes back in through
controller.set(...)ordropdown-menu:set. - Switch open-state listeners from
dropdown-menu:changetodropdown-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.
License
MIT
