Skip to content

DropdownMenu Menu

A primitive for building dropdown menus with keyboard navigation, typeahead, radio groups, and checkbox items.

Updated View as Markdown

DropdownMenu Menu

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

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

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.

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.

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

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

Expected Markup

The primitive expects the following slot structure.

<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.
interface DropdownMenuOpenChangeDetail {
  open: boolean;
  previousOpen: boolean;
  source: "pointer" | "keyboard" | "programmatic" | "init";
  reason:
    | "trigger"
    | "select"
    | "outside"
    | "escape"
    | "tab"
    | "programmatic"
    | "init";
}
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.

interface DropdownMenuValueChangeDetail {
  value: string | null;
  previousValue: string | null;
  item: HTMLElement | null;
  previousItem: HTMLElement | null;
  source: "pointer" | "keyboard" | "programmatic" | "restore";
}
interface DropdownMenuValuesChangeDetail {
  values: string[];
  previousValues: string[];
  changedValue: string | null;
  checked: boolean | null;
  item: HTMLElement | null;
  source: "pointer" | "keyboard" | "programmatic" | "restore";
}
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.

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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close