Skip to content

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

SlotPurpose
dropdown-menuRoot element. Controller is bound here.
dropdown-menu-triggerButton that toggles the menu.
dropdown-menu-contentPopup container positioned relative to the trigger.
dropdown-menu-itemSelectable menu action.
dropdown-menu-labelNon-interactive label for a group of items.
dropdown-menu-groupWrapper that groups items under a label.
dropdown-menu-separatorVisual divider between item groups.
dropdown-menu-shortcutKeyboard shortcut hint displayed alongside an item.
dropdown-menu-checkbox-itemToggleable item that emits checked state changes.
dropdown-menu-radio-itemMutually exclusive selection item within the menu.

Data Attributes

AttributeApplies toDescription
data-value*-item, *-checkbox-item, *-radio-itemValue emitted on selection or state change.
data-disabled*-item, *-checkbox-item, *-radio-itemPrevents selection, highlight, and hover interaction.
data-variant="destructive"*-itemMarks the item as a destructive action.
data-type*-itemCustom type annotation attached to the element.

The controller manages these state attributes on the root:

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

AttributeDescription
data-highlightedPresent on the currently highlighted item.
data-checkedPresent on a checked checkbox/radio item.

Events

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

Outbound

EventDetailDescription
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

EventDetailDescription
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)

OptionTypeDefaultDescription
defaultOpenbooleanfalseInitial open state.
defaultValuestring | nullInitial radio selection value.
defaultValuesstring[]Initial checkbox selection values.
onOpenChange(open: boolean) => voidCalled when open state changes.
onPortalMounted(container: HTMLElement) => voidAfter portaled menu mounts on open.
onSelect(value: string) => voidCalled when a user selects an item.
onValueChange(value: string | null) => voidCalled when the radio value changes.
onValuesChange(values: string[]) => voidCalled when checkbox values change.
closeOnClickOutsidebooleantrueClose when clicking outside the menu.
closeOnEscapebooleantrueClose when pressing Escape.
closeOnSelectbooleantrueClose 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.
sideOffsetnumber4Distance in px from the trigger.
alignOffsetnumber0Offset in px from the alignment edge.
avoidCollisionsbooleantrueOverride side/align to prevent viewport collisions.
collisionPaddingnumber8Padding between content and viewport edges when avoiding collisions.
lockScrollbooleantrueLock body scroll when the menu is open.
highlightItemOnHoverbooleantrueWhether 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

MemberTypeDescription
isOpenboolean (getter)Current open state.
valuestring | null (getter)Current committed radio selection value.
valuesstring[] (getter)Current committed checkbox selection values.
highlightedValuestring | null (getter)data-value of the currently highlighted item.
open()() => voidOpen the menu.
close()() => voidClose the menu.
toggle()() => voidToggle the menu open state.
set(detail)(detail: DropdownMenuSetDetail) => voidSet open, value, values, or highlightedValue programmatically.
destroy()() => voidRemove 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:

KeyBehavior
ArrowDownMove highlight to the next enabled item.
ArrowUpMove highlight to the previous enabled item.
HomeMove highlight to the first enabled item.
EndMove highlight to the last enabled item.
Enter / SpaceActivate the highlighted item.
EscapeClose the menu.
TabClose the menu.
Printable keysTypeahead: 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.