implement
Dropdown Menu

Dropdown Menu

A menu of actions opened from a button.

import { signal } from "@implementjs/core";
import {
	DropdownMenu,
	DropdownMenuCheckboxItem,
	DropdownMenuContent,
	DropdownMenuGroup,
	DropdownMenuGroupHeading,
	DropdownMenuItem,
	DropdownMenuRadioGroup,
	DropdownMenuRadioItem,
	DropdownMenuSeparator,
	DropdownMenuSub,
	DropdownMenuSubContent,
	DropdownMenuSubTrigger,
	DropdownMenuTrigger,
} from "@/lib/components/ui/dropdown-menu";

export default function DropdownMenuDemo() {
	const showStatusBar = signal(true);
	const position = signal<string | null>("bottom");

	return DropdownMenu(
		DropdownMenuTrigger("Open menu"),
		DropdownMenuContent(
			{ class: "w-56" },
			DropdownMenuGroup(
				DropdownMenuGroupHeading("My Account"),
				DropdownMenuItem({ onSelect: () => console.log("profile") }, "Profile"),
				DropdownMenuItem({ onSelect: () => console.log("billing") }, "Billing"),
				DropdownMenuItem({ disabled: true }, "Settings"),
			),
			DropdownMenuSub(
				DropdownMenuSubTrigger("Invite people"),
				DropdownMenuSubContent(
					DropdownMenuItem({ onSelect: () => console.log("email") }, "Email"),
					DropdownMenuItem({ onSelect: () => console.log("message") }, "Message"),
					DropdownMenuSeparator(),
					DropdownMenuItem({ onSelect: () => console.log("invite-link") }, "Copy invite link"),
				),
			),
			DropdownMenuSeparator(),
			DropdownMenuCheckboxItem({ checked: showStatusBar, closeOnSelect: false }, "Status bar"),
			DropdownMenuSeparator(),
			DropdownMenuRadioGroup(
				{ value: position },
				DropdownMenuGroupHeading("Panel position"),
				DropdownMenuRadioItem({ value: "top" }, "Top"),
				DropdownMenuRadioItem({ value: "bottom" }, "Bottom"),
				DropdownMenuRadioItem({ value: "right" }, "Right"),
			),
		),
	);
}

A dropdown menu shows a list of actions when its trigger is pressed. DropdownMenu is the root, DropdownMenuTrigger the button, and DropdownMenuContent the floating panel of items. It shares its content, items, and keyboard model with Context Menu and Menubar — only how the menu opens differs.

import {
	DropdownMenu,
	DropdownMenuContent,
	DropdownMenuItem,
	DropdownMenuTrigger,
} from "@implementjs/primitives";

DropdownMenu(
	DropdownMenuTrigger("Open"),
	DropdownMenuContent(
		DropdownMenuItem({ onSelect: () => save() }, "Save"),
		DropdownMenuItem({ onSelect: () => rename() }, "Rename…"),
	),
);

Each part accepts optional props and children — pass a props object when you need attributes, or pass children directly. See createComponent. Extra props are forwarded onto the underlying Div or Button.

Open state

DropdownMenu owns whether the menu is open. Pass a boolean to seed it, or a signal to control it from outside. Clicking the trigger toggles; Escape, selecting an item, or interacting outside closes.

Items

DropdownMenuItem runs onSelect when clicked or activated with Enter or Space, then closes the menu — pass closeOnSelect: false to keep it open. disabled items are skipped by the keyboard and set data-disabled.

Beyond the plain item there are stateful ones:

  • DropdownMenuCheckboxItem holds a checked boolean (role="menuitemcheckbox"); selecting toggles it.
  • DropdownMenuRadioGroup holds a value, and each DropdownMenuRadioItem inside it is one choice (role="menuitemradio").

Both accept signals, so the menu state and your app state are the same thing:

const showStatusBar = signal(true);

DropdownMenuCheckboxItem({ checked: showStatusBar, closeOnSelect: false }, "Status bar");

Structure

DropdownMenuGroup wraps related items in role="group"; give the group a name with DropdownMenuGroupHeading, and the group labels itself with it. DropdownMenuSeparator draws a role="separator" line between sections. DropdownMenuPortal is the core Portal helper for escaping overflow and stacking contexts.

DropdownMenuSub nests a menu inside a content. Its DropdownMenuSubTrigger is a regular item of the parent — arrows reach it, it highlights like the rest — that opens the DropdownMenuSubContent beside it instead of selecting:

DropdownMenuSub(
	DropdownMenuSubTrigger("Invite people"),
	DropdownMenuSubContent(
		DropdownMenuItem({ onSelect: () => byEmail() }, "Email"),
		DropdownMenuItem({ onSelect: () => byLink() }, "Copy invite link"),
	),
);

The submenu opens when the pointer rests on the trigger (openDelay, default 100ms), or with ArrowRight, Enter, or Space — keyboard opens focus its first item. ArrowLeft inside the panel closes it and returns focus to the trigger; moving the pointer to a sibling item closes it too. Selecting an item inside a submenu closes the whole menu, and submenus nest as deep as you need.

Keyboard

The trigger opens with Enter, Space, or ArrowDown — keyboard opens focus the first item. Inside, ArrowUp and ArrowDown move (wrapping unless loop: false), Home and End jump to the ends, typing a character jumps to the next item starting with it, Enter and Space activate, and Escape closes and returns focus to the trigger. Tab closes the menu, since a menu is not part of the page's tab order.

Positioning and styling

DropdownMenuContent positions against the trigger with side, align, and offset, and stays put on scroll and resize. Like Popover, the primitive does not hide the closed panel — style it against data-state. While open, the page behind cannot scroll; pass preventScroll: false on the root to leave it scrollable.

DropdownMenuContent({
	class: "absolute z-50 min-w-32 rounded-md border bg-popover p-1 data-[state=closed]:hidden",
});

Items expose data-highlighted while focused, so hover and keyboard highlight are one selector; checkbox and radio items expose data-state as "checked" or "unchecked". Every part sets a data-dropdown-menu-* attribute.

API Reference

The root. Owns whether the menu is open and provides that to the parts inside it.

PropTypeDefaultDescription
openSignal<boolean> | booleanfalseThe open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state.
preventScrollbooleantrueWhen true, the page behind cannot scroll while the menu is open. The panel can still scroll if you give it overflow.

Opens the menu on click, Enter, Space, or ArrowDown. Sets aria-haspopup, aria-expanded, and aria-controls. Renders a Button; extra props are forwarded onto it.

PropTypeDefaultDescription
disabledSignal<boolean> | booleanfalsePrevents opening the menu. Sets disabled and data-disabled.
Data attributeValue
[data-dropdown-menu-trigger]Present
[data-state]"open" | "closed"
[data-disabled]Present when disabled

The floating panel of items. Sets role="menu". Style it against data-state and data-side; the primitive does not hide it for you. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
side"top" | "bottom" | "left" | "right""bottom"Preferred side of the anchor to place the panel.
align"start" | "center" | "end""start"How the panel aligns along the chosen side.
offsetnumber0Distance in pixels between the anchor and the panel.
loopbooleantrueWhether arrow keys wrap from the last item back to the first.
Data attributeValue
[data-dropdown-menu-content]Present
[data-state]"open" | "closed"
[data-side]"top" | "bottom" | "left" | "right"
[data-align]"start" | "center" | "end"

One action. Sets role="menuitem"; focus follows the pointer. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
onSelect() => voidRuns when the item is activated with a click, Enter, or Space.
closeOnSelectbooleantrueWhether selecting the item closes the menu.
disabledSignal<boolean> | booleanfalsePrevents selecting the item; the keyboard skips it.
Data attributeValue
[data-dropdown-menu-item]Present
[data-highlighted]Present while focused
[data-disabled]Present when disabled

An item holding a checked state. Sets role="menuitemcheckbox" and aria-checked. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
checkedSignal<boolean> | booleanfalseThe checked state; selecting toggles it. Pass a signal to control it from outside.
closeOnSelectbooleantrueWhether selecting the item closes the menu.
disabledSignal<boolean> | booleanfalsePrevents selecting the item; the keyboard skips it.
Data attributeValue
[data-dropdown-menu-checkbox-item]Present
[data-state]"checked" | "unchecked"
[data-highlighted]Present while focused
[data-disabled]Present when disabled

Wraps radio items and owns which one is checked. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
valueSignal<string | null> | string | nullnullThe checked item. Pass a signal to control it from outside.
Data attributeValue
[data-dropdown-menu-radio-group]Present

One choice in a radio group. Sets role="menuitemradio" and aria-checked. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
value*stringIdentifies the item. Must be unique within the radio group.
closeOnSelectbooleantrueWhether selecting the item closes the menu.
disabledSignal<boolean> | booleanfalsePrevents selecting the item; the keyboard skips it.
Data attributeValue
[data-dropdown-menu-radio-item]Present
[data-state]"checked" | "unchecked"
[data-highlighted]Present while focused
[data-disabled]Present when disabled

A nested menu. Wraps a sub trigger and its sub content inside a parent content.

PropTypeDefaultDescription
openSignal<boolean> | booleanfalseThe open state. Pass a signal to control it from outside; a boolean seeds uncontrolled state.

The item that opens its submenu — on hover after openDelay, ArrowRight, Enter, Space, or click. Sets aria-haspopup and aria-expanded. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
openDelaynumber100How long the pointer must rest on the trigger before the submenu opens, in milliseconds.
disabledSignal<boolean> | booleanfalsePrevents opening the submenu; the keyboard skips the item.
Data attributeValue
[data-dropdown-menu-sub-trigger]Present
[data-dropdown-menu-item]Present
[data-state]"open" | "closed"
[data-highlighted]Present while focused
[data-disabled]Present when disabled

The submenu's panel, positioned against its trigger. ArrowLeft closes it and returns focus; the keyboard model inside matches the parent content. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
side"top" | "bottom" | "left" | "right""right"Preferred side of the sub trigger to place the panel.
align"start" | "center" | "end""start"How the panel aligns along the chosen side.
offsetnumber0Distance in pixels between the sub trigger and the panel.
loopbooleantrueWhether arrow keys wrap from the last item back to the first.
Data attributeValue
[data-dropdown-menu-sub-content]Present
[data-state]"open" | "closed"
[data-side]"top" | "bottom" | "left" | "right"
[data-align]"start" | "center" | "end"

Wraps related items in role="group", labeled by the heading placed inside it. Renders a Div; extra props are forwarded onto it.

Data attributeValue
[data-dropdown-menu-group]Present

Names the group it sits in; the group points aria-labelledby at it. Renders a Div; extra props are forwarded onto it.

Data attributeValue
[data-dropdown-menu-group-heading]Present

A role="separator" line between sections. Renders a Div; extra props are forwarded onto it.

Data attributeValue
[data-dropdown-menu-separator]Present

The core Portal helper, for rendering the panel into another DOM parent to escape overflow and stacking.