implement
Dropdown Menu

Dropdown Menu

A menu of actions hanging off a trigger button.

import { signal } from "@implementjs/core";
import {
	DropdownMenu,
	DropdownMenuCheckboxGroup,
	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");
	const visible = signal(["activity-bar"]);

	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(),
			DropdownMenuCheckboxGroup(
				{ value: visible },
				DropdownMenuGroupHeading("Panels"),
				DropdownMenuCheckboxItem({ value: "activity-bar", closeOnSelect: false }, "Activity bar"),
				DropdownMenuCheckboxItem({ value: "terminal", closeOnSelect: false }, "Terminal"),
			),
			DropdownMenuSeparator(),
			DropdownMenuRadioGroup(
				{ value: position },
				DropdownMenuGroupHeading("Panel position"),
				DropdownMenuRadioItem({ value: "top" }, "Top"),
				DropdownMenuRadioItem({ value: "bottom" }, "Bottom"),
				DropdownMenuRadioItem({ value: "right" }, "Right"),
			),
		),
	);
}

Installation

npx jsrepo add @implementjs/ui/dropdown-menu

jsrepo pulls button along with it, and installs @implementjs/lucide.

Usage

DropdownMenuTrigger renders through the button styles and defaults to outline. Everything below it is the shared menu look: a popover panel that scales in from the side it opens on, items that fill in when highlighted, and check and radio indicators in a fixed left gutter so labels line up whether or not an item has one.

Submenus open without a transition — a submenu should feel instant, not animated.

import {
	DropdownMenu,
	DropdownMenuContent,
	DropdownMenuGroup,
	DropdownMenuGroupHeading,
	DropdownMenuItem,
	DropdownMenuSeparator,
} from "@/lib/components/ui/dropdown-menu";

DropdownMenu(
	DropdownMenuTrigger("Open menu"),
	DropdownMenuContent(
		{ class: "w-56" },
		DropdownMenuGroup(
			DropdownMenuGroupHeading("My Account"),
			DropdownMenuItem({ onSelect: profile }, "Profile"),
			DropdownMenuItem({ disabled: true }, "Settings"),
		),
		DropdownMenuSeparator(),
		DropdownMenuItem({ onSelect: signOut }, "Sign out"),
	),
);

Checkbox and radio items

Both render their own indicator, so the item is just its label. A checkbox item usually wants closeOnSelect: false — toggling a setting is not leaving the menu:

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

DropdownMenuRadioGroup(
	{ value: position },
	DropdownMenuRadioItem({ value: "top" }, "Top"),
	DropdownMenuRadioItem({ value: "bottom" }, "Bottom"),
);

DropdownMenuCheckboxGroup holds a set of checkbox items as one array of values, each item named by its own value:

DropdownMenuCheckboxGroup(
	{ value: visible },
	DropdownMenuCheckboxItem({ value: "status-bar", closeOnSelect: false }, "Status bar"),
	DropdownMenuCheckboxItem({ value: "panel", closeOnSelect: false }, "Panel"),
);

Drawing your own indicator

indicator replaces the check a checkbox item renders. The left padding the default one is absolutely positioned into comes off with it, so a custom indicator sits in the row's flow and you place it yourself:

DropdownMenuCheckboxItem({ value: "bug", indicator: MyIndicator() }, "Bug");

Anything you draw reads its state off the row, which is a group/menu-item — group-data-[state=checked]/menu-item: for checked, group-data-[highlighted]/menu-item: for the row under the pointer.

To draw a real checkbox there, reach for its decorative prop. The row is already the role="menuitemcheckbox", so a second control inside it would put two checked states on one row; decorative renders the box as an aria-hidden span — the look and the click toggle, none of the semantics:

Checkbox({ decorative: true, checked: isChecked });

The state it shows is the group's, reached through a two-way bind rather than copied — selected.bind((labels) => labels.includes(value), ...) is a Signal<boolean> view of one label's place in the array, so the box and the row toggle the same thing.

That is half of the pattern below; the other half is that closeOnSelect belongs to the item, so an element inside it can stop its own click before the item ever selects. Clicking the checkbox toggles the label and leaves the menu open for the next one, while clicking the rest of the row toggles it and closes.

ui-fix

import { Div, P, Span, signal, type Signal } from "@implementjs/core";
import { TagIcon } from "@implementjs/lucide";
import { Checkbox } from "@/lib/components/ui/checkbox";
import {
	DropdownMenu,
	DropdownMenuCheckboxGroup,
	DropdownMenuCheckboxItem,
	DropdownMenuContent,
	DropdownMenuGroupHeading,
	DropdownMenuTrigger,
} from "@/lib/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";

const LABELS = [
	{ value: "ui-fix", name: "UI Fix", emoji: "🎨", dot: "bg-orange-400" },
	{ value: "bug", name: "Bug", emoji: "🐛", dot: "bg-red-400" },
	{ value: "docs", name: "Docs", emoji: "📝", dot: "bg-green-400" },
	{ value: "improvement", name: "Improvement", emoji: "⛏️", dot: "bg-blue-400" },
	{ value: "feature", name: "Feature", emoji: "🚀", dot: "bg-purple-400" },
	{ value: "question", name: "Question", emoji: "❓", dot: "bg-yellow-400" },
];

/**
 * A two-way view of one label's place in the group's array: reading is
 * `includes`, writing adds or removes. The checkbox toggles it like any other
 * signal, so the array stays the only copy of the state — nothing to keep in
 * sync with the row.
 */
function membership(selected: Signal<string[]>, value: string): Signal<boolean> {
	return selected.bind(
		(labels) => labels.includes(value),
		(labels, checked) => (checked ? [...labels, value] : labels.filter((label) => label !== value)),
	);
}

/**
 * The row's indicator, drawn as a real checkbox. `decorative` renders it as a
 * span outside the accessibility tree — the row is already the
 * `menuitemcheckbox` — while the click still toggles. It swallows that click,
 * so toggling from here never reaches the item and the menu stays open;
 * clicking anywhere else on the row goes through the item and closes.
 */
function LabelCheckbox(selected: Signal<string[]>, value: string) {
	return Checkbox({
		decorative: true,
		checked: membership(selected, value),
		onClick: (e: MouseEvent) => e.stopPropagation(),
		// idle rows show only their dot; the box fades in under the pointer, or stays for a checked one
		class: cn(
			"transition-opacity opacity-0",
			"group-data-[highlighted]/menu-item:opacity-100 group-data-[state=checked]/menu-item:opacity-100",
		),
	});
}

export default function DropdownMenuLabelsDemo() {
	const selected = signal(["ui-fix"]);

	return Div(
		{ class: "flex w-full max-w-xs flex-col items-center gap-3" },
		DropdownMenu(
			DropdownMenuTrigger(
				{ size: "sm" },
				TagIcon({ "aria-hidden": true, class: "size-4" }),
				"Labels",
			),
			DropdownMenuContent(
				{ class: "w-56" },
				DropdownMenuCheckboxGroup(
					{ value: selected },
					DropdownMenuGroupHeading("Add labels..."),
					...LABELS.map((label) =>
						DropdownMenuCheckboxItem(
							{ value: label.value, indicator: LabelCheckbox(selected, label.value) },
							Span({ "aria-hidden": true, class: cn("size-2 shrink-0 rounded-full", label.dot) }),
							Span({ "aria-hidden": true }, label.emoji),
							Span(label.name),
						),
					),
				),
			),
		),
		P(
			{ class: "text-sm text-muted-foreground" },
			selected.bind((labels) => (labels.length === 0 ? "No labels" : labels.join(", "))),
		),
	);
}

The row stays a single role="menuitemcheckbox", so it keeps one accessible name and one checked state. The cost is that the split is pointer-only: Enter and Space activate the row, which toggles and closes. If keyboard users need to check several labels in one pass, put closeOnSelect: false on the items and let the row behave like the checkbox does.

Restyling the menu

Every class is written where it is used, so the panel, the items, the group headings, and the indicators are all in this file and nothing outside it changes when you edit them. The context menu, the menubar, and the select are drawn to match, but each carries its own copy — restyle them the same way, in their own files.

API Reference

Every prop the styling does not consume is forwarded to the Dropdown Menu primitive, so the tables below are the whole surface — the behavior props and the styling ones together.

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.
onOpenChange(open: boolean) => void—Runs whenever the menu opens or closes.
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
variant"default" | "destructive" | "outline" | "secondary" | "ghost" | "link""outline"Which button style the part renders with. Also set as data-variant.
size"default" | "xs" | "sm" | "lg" | "icon" | "icon-xs" | "icon-sm" | "icon-lg""default"The button's height and padding scale. Also set as data-size.
disabledReadable<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". Styled as a popover panel that fades and scales in from the side it opens on, and hides itself when closed. 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.
offsetnumber4Distance 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() => void—Runs when the item is activated with a click, Enter, or Space.
closeOnSelectbooleantrueWhether selecting the item closes the menu.
disabledReadable<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

Wraps checkbox items and owns which of them are checked, as one array of values. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
valueSignal<ItemValue[]> | ItemValue[][]The values of the checked items, where ItemValue is string | number. Pass a signal to control them from outside.
onValueChange(value: ItemValue[]) => void—Runs whenever the set of checked items changes.
Data attributeValue
[data-dropdown-menu-checkbox-group]Present

An item holding a checked state. Sets role="menuitemcheckbox" and aria-checked. Renders its own check indicator, shown while the item is checked. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
indicatorChild—Drawn in place of the default check. The left padding the default one is absolutely positioned into comes off with it, so a custom indicator sits in the row's flow.
valueItemValue—Identifies the item inside a checkbox group, as a string or a number. Must be unique within the group; ignored outside one.
checkedSignal<boolean> | booleanfalseThe checked state; selecting toggles it. Pass a signal to control it from outside. Inside a checkbox group the group's value owns it instead.
onCheckedChange(checked: boolean) => void—Runs whenever the item's checked state changes, inside a group or on its own.
closeOnSelectbooleantrueWhether selecting the item closes the menu.
disabledReadable<boolean> | booleanfalsePrevents selecting the item; the keyboard skips it.
Data attributeValue
[data-dropdown-menu-checkbox-item]Present
[data-value]The item's value, when it has one
[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<ItemValue | null> | ItemValue | nullnullThe checked item, where ItemValue is string | number. Pass a signal to control it from outside.
onValueChange(value: ItemValue | null) => void—Runs whenever the checked item changes. null once nothing is checked.
Data attributeValue
[data-dropdown-menu-radio-group]Present

One choice in a radio group. Sets role="menuitemradio" and aria-checked. Renders its own dot indicator, shown while the item is selected. Renders a Div; extra props are forwarded onto it.

PropTypeDefaultDescription
value*ItemValue—Identifies the item, as a string or a number. Must be unique within the radio group.
closeOnSelectbooleantrueWhether selecting the item closes the menu.
disabledReadable<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.
onOpenChange(open: boolean) => void—Runs whenever the submenu opens or closes.

The item that opens its submenu — on hover after openDelay, ArrowRight, Enter, Space, or click. Sets aria-haspopup and aria-expanded. The styled trigger appends a chevron pointing into the submenu. 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.
disabledReadable<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

A submenu's panel. Styled like the content panel but without the transition — a submenu should feel instant. 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.
offsetnumber8Distance 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.