Dropdown Menu
A menu of actions opened from a 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"),
),
),
);
}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.
onOpenChange reports every open and close, whether it came from the trigger, a selected item, Escape, or a write to a signal you passed in. Submenus take the same prop on DropdownMenuSub.
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.
disabled — on the trigger, on an item, on a sub trigger — is only ever read, so it takes anything readable, not just a signal you own. A derived off loaded data disables the menu without a second copy of the state:
const board = derived([workspace], (value) => value.board);
DropdownMenuTrigger({ disabled: board.bind((value) => value !== "public") }, "Visibility");
Beyond the plain item there are stateful ones:
DropdownMenuCheckboxItemholds acheckedboolean (role="menuitemcheckbox"); selecting toggles it.DropdownMenuRadioGroupholds avalue, and eachDropdownMenuRadioIteminside 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");
When several checkbox items belong together, DropdownMenuCheckboxGroup holds all of them as one array of values instead of a boolean each. Give every item inside it a value, and the group's array is the set that is checked — selecting an item adds or removes its value:
const visible = signal(["status-bar", "activity-bar"]);
DropdownMenuCheckboxGroup(
{ value: visible },
DropdownMenuGroupHeading("Panels"),
DropdownMenuCheckboxItem({ value: "status-bar", closeOnSelect: false }, "Status bar"),
DropdownMenuCheckboxItem({ value: "activity-bar", closeOnSelect: false }, "Activity bar"),
DropdownMenuCheckboxItem({ value: "panel", closeOnSelect: false }, "Panel"),
);
The group is a role="group" like DropdownMenuGroup, so a DropdownMenuGroupHeading placed inside names it. Inside a group the group's array owns each item's checked state and the item's own checked prop is ignored; an item with no value keeps its own boolean.
Item values are strings or numbers, in the checkbox group and in the radio group alike, so a row id from a database can go straight in without being stringified and parsed again:
const size = signal<number | null>(14);
DropdownMenuRadioGroup(
{ value: size },
DropdownMenuRadioItem({ value: 12 }, "12px"),
DropdownMenuRadioItem({ value: 14 }, "14px"),
);
The DOM only speaks strings, so data-value on the item is the number written out. Values are matched by identity, though, so 12 and "12" are two different items — pick one shape per group.
A checkbox indicator, and two click targets
A checkbox item is a Div you fill yourself, so the checked indicator is whatever you draw against data-state — a check, a dot, or a checkbox like a label picker's. And because closeOnSelect is a property of the item, an element nested inside it can opt out of that behavior on its own: stop the click before it reaches the item and the item never selects, so the menu stays open.
That is the whole trick behind the pattern below. Clicking the checkbox toggles the label and leaves the menu open for the next one; clicking anywhere else on the row toggles it and closes. On the primitive the indicator is just the first child you pass; the demo goes through the styled item's indicator prop, which swaps the check it would otherwise render.
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 item stays a single role="menuitemcheckbox" — the checkbox is aria-hidden decoration with a click handler, not a nested control — so the row 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.
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.
Submenus
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
DropdownMenu
The root. Owns whether the menu is open and provides that to the parts inside it.
| Prop | Type | Default | Description |
|---|---|---|---|
open | Signal<boolean> | boolean | false | The 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. |
preventScroll | boolean | true | When true, the page behind cannot scroll while the menu is open. The panel can still scroll if you give it overflow. |
DropdownMenuTrigger
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.
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | Readable<boolean> | boolean | false | Prevents opening the menu. Sets disabled and data-disabled. |
| Data attribute | Value |
|---|---|
[data-dropdown-menu-trigger] | Present |
[data-state] | "open" | "closed" |
[data-disabled] | Present when disabled |
DropdownMenuContent
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.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
offset | number | 0 | Distance in pixels between the anchor and the panel. |
loop | boolean | true | Whether arrow keys wrap from the last item back to the first. |
| Data attribute | Value |
|---|---|
[data-dropdown-menu-content] | Present |
[data-state] | "open" | "closed" |
[data-side] | "top" | "bottom" | "left" | "right" |
[data-align] | "start" | "center" | "end" |
DropdownMenuItem
One action. Sets role="menuitem"; focus follows the pointer. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
onSelect | () => void | — | Runs when the item is activated with a click, Enter, or Space. |
closeOnSelect | boolean | true | Whether selecting the item closes the menu. |
disabled | Readable<boolean> | boolean | false | Prevents selecting the item; the keyboard skips it. |
| Data attribute | Value |
|---|---|
[data-dropdown-menu-item] | Present |
[data-highlighted] | Present while focused |
[data-disabled] | Present when disabled |
DropdownMenuCheckboxGroup
Wraps checkbox items and owns which of them are checked, as one array of values. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
value | Signal<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 attribute | Value |
|---|---|
[data-dropdown-menu-checkbox-group] | Present |
DropdownMenuCheckboxItem
An item holding a checked state. Sets role="menuitemcheckbox" and aria-checked. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
value | ItemValue | — | Identifies the item inside a checkbox group, as a string or a number. Must be unique within the group; ignored outside one. |
checked | Signal<boolean> | boolean | false | The 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. |
closeOnSelect | boolean | true | Whether selecting the item closes the menu. |
disabled | Readable<boolean> | boolean | false | Prevents selecting the item; the keyboard skips it. |
| Data attribute | Value |
|---|---|
[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 |
DropdownMenuRadioGroup
Wraps radio items and owns which one is checked. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
value | Signal<ItemValue | null> | ItemValue | null | null | The 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 attribute | Value |
|---|---|
[data-dropdown-menu-radio-group] | Present |
DropdownMenuRadioItem
One choice in a radio group. Sets role="menuitemradio" and aria-checked. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
value* | ItemValue | — | Identifies the item, as a string or a number. Must be unique within the radio group. |
closeOnSelect | boolean | true | Whether selecting the item closes the menu. |
disabled | Readable<boolean> | boolean | false | Prevents selecting the item; the keyboard skips it. |
| Data attribute | Value |
|---|---|
[data-dropdown-menu-radio-item] | Present |
[data-state] | "checked" | "unchecked" |
[data-highlighted] | Present while focused |
[data-disabled] | Present when disabled |
DropdownMenuSub
A nested menu. Wraps a sub trigger and its sub content inside a parent content.
| Prop | Type | Default | Description |
|---|---|---|---|
open | Signal<boolean> | boolean | false | The 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. |
DropdownMenuSubTrigger
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.
| Prop | Type | Default | Description |
|---|---|---|---|
openDelay | number | 100 | How long the pointer must rest on the trigger before the submenu opens, in milliseconds. |
disabled | Readable<boolean> | boolean | false | Prevents opening the submenu; the keyboard skips the item. |
| Data attribute | Value |
|---|---|
[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 |
DropdownMenuSubContent
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.
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
offset | number | 0 | Distance in pixels between the sub trigger and the panel. |
loop | boolean | true | Whether arrow keys wrap from the last item back to the first. |
| Data attribute | Value |
|---|---|
[data-dropdown-menu-sub-content] | Present |
[data-state] | "open" | "closed" |
[data-side] | "top" | "bottom" | "left" | "right" |
[data-align] | "start" | "center" | "end" |
DropdownMenuGroup
Wraps related items in role="group", labeled by the heading placed inside it. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-dropdown-menu-group] | Present |
DropdownMenuGroupHeading
Names the group it sits in; the group points aria-labelledby at it. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-dropdown-menu-group-heading] | Present |
DropdownMenuSeparator
A role="separator" line between sections. Renders a Div; extra props are forwarded onto it.
| Data attribute | Value |
|---|---|
[data-dropdown-menu-separator] | Present |
DropdownMenuPortal
The core Portal helper, for rendering the panel into another DOM parent to escape overflow and stacking.