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.
Copy the file below to src/lib/components/ui/dropdown-menu.ts. It imports cn from utils.ts, which belongs at src/lib/utils.ts, and button from the same directory — copy those in beside it too. Then, on top of @implementjs/core and @implementjs/primitives:
npm install @implementjs/lucide
import { Span, type Child, type ComponentProps } from "@implementjs/core";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "@implementjs/lucide";
import {
DropdownMenu as DropdownMenuPrimitive,
DropdownMenuCheckboxGroup as DropdownMenuCheckboxGroupPrimitive,
DropdownMenuCheckboxItem as DropdownMenuCheckboxItemPrimitive,
DropdownMenuContent as DropdownMenuContentPrimitive,
DropdownMenuGroup as DropdownMenuGroupPrimitive,
DropdownMenuGroupHeading as DropdownMenuGroupHeadingPrimitive,
DropdownMenuItem as DropdownMenuItemPrimitive,
DropdownMenuRadioGroup as DropdownMenuRadioGroupPrimitive,
DropdownMenuRadioItem as DropdownMenuRadioItemPrimitive,
DropdownMenuSeparator as DropdownMenuSeparatorPrimitive,
DropdownMenuSub as DropdownMenuSubPrimitive,
DropdownMenuSubContent as DropdownMenuSubContentPrimitive,
DropdownMenuSubTrigger as DropdownMenuSubTriggerPrimitive,
DropdownMenuTrigger as DropdownMenuTriggerPrimitive,
} from "@implementjs/primitives";
import { buttonVariants, type ButtonSize, type ButtonVariant } from "./button";
import { cn } from "@/lib/utils";
import { createComponent } from "@implementjs/primitives";
export type DropdownMenuProps = ComponentProps<typeof DropdownMenuPrimitive>;
export const DropdownMenu = DropdownMenuPrimitive;
export type DropdownMenuTriggerProps = ComponentProps<typeof DropdownMenuTriggerPrimitive> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
export const DropdownMenuTrigger = createComponent(function DropdownMenuTrigger(
{ class: className, variant = "outline", size = "default", ...props }: DropdownMenuTriggerProps,
...children: Child[]
) {
return DropdownMenuTriggerPrimitive(
{
...props,
"data-slot": "dropdown-menu-trigger",
"data-variant": variant,
"data-size": size,
class: cn(buttonVariants({ variant, size }), className),
},
...children,
);
});
export type DropdownMenuContentProps = ComponentProps<typeof DropdownMenuContentPrimitive>;
export const DropdownMenuContent = createComponent(function DropdownMenuContent(
{ class: className, offset = 4, ...props }: DropdownMenuContentProps,
...children: Child[]
) {
return DropdownMenuContentPrimitive(
{
offset,
...props,
"data-slot": "dropdown-menu-content",
// no overflow clipping: sub-content panels render nested inside and extend past this box
class: cn(
"absolute z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"origin-(--ip-dropdown-menu-content-transform-origin)",
"transition-[opacity,translate,scale,display] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] transition-discrete motion-reduce:transition-none",
"data-[state=open]:block data-[state=open]:translate-0 data-[state=open]:scale-100 data-[state=open]:opacity-100",
"data-[state=closed]:pointer-events-none data-[state=closed]:hidden data-[state=closed]:scale-95 data-[state=closed]:opacity-0",
"data-[state=closed]:data-[side=bottom]:-translate-y-2 data-[state=closed]:data-[side=top]:translate-y-2 data-[state=closed]:data-[side=left]:translate-x-2 data-[state=closed]:data-[side=right]:-translate-x-2",
"starting:data-[state=open]:opacity-0 starting:data-[state=open]:scale-95",
"starting:data-[state=open]:data-[side=bottom]:-translate-y-2 starting:data-[state=open]:data-[side=top]:translate-y-2 starting:data-[state=open]:data-[side=left]:translate-x-2 starting:data-[state=open]:data-[side=right]:-translate-x-2",
className,
),
},
...children,
);
});
export type DropdownMenuItemProps = ComponentProps<typeof DropdownMenuItemPrimitive>;
export const DropdownMenuItem = createComponent(function DropdownMenuItem(
{ class: className, ...props }: DropdownMenuItemProps,
...children: Child[]
) {
return DropdownMenuItemPrimitive(
{
...props,
"data-slot": "dropdown-menu-item",
class: cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
),
},
...children,
);
});
export type DropdownMenuCheckboxGroupProps = ComponentProps<
typeof DropdownMenuCheckboxGroupPrimitive
>;
export const DropdownMenuCheckboxGroup = DropdownMenuCheckboxGroupPrimitive;
export type DropdownMenuCheckboxItemProps = ComponentProps<
typeof DropdownMenuCheckboxItemPrimitive
> & {
/**
* The checked indicator, drawn in place of the default check. The left
* padding the default one is absolutely positioned into comes off with it:
* a custom indicator sits in the row's flow, so placing it is yours.
*/
indicator?: Child;
};
export const DropdownMenuCheckboxItem = createComponent(function DropdownMenuCheckboxItem(
{ class: className, indicator, ...props }: DropdownMenuCheckboxItemProps,
...children: Child[]
) {
return DropdownMenuCheckboxItemPrimitive(
{
...props,
"data-slot": "dropdown-menu-checkbox-item",
class: cn(
"group/menu-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
// room in the gutter for the default check, which is positioned into it
indicator === undefined && "py-1.5 pr-2 pl-8",
className,
),
},
indicator ??
Span(
{
"data-slot": "menu-item-indicator",
class: "pointer-events-none absolute left-2 flex size-3.5 items-center justify-center",
},
CheckIcon({
"aria-hidden": true,
class: "size-4 hidden group-data-[state=checked]/menu-item:block",
}),
),
...children,
);
});
export type DropdownMenuRadioGroupProps = ComponentProps<typeof DropdownMenuRadioGroupPrimitive>;
export const DropdownMenuRadioGroup = DropdownMenuRadioGroupPrimitive;
export type DropdownMenuRadioItemProps = ComponentProps<typeof DropdownMenuRadioItemPrimitive>;
export const DropdownMenuRadioItem = createComponent(function DropdownMenuRadioItem(
{ class: className, ...props }: DropdownMenuRadioItemProps,
...children: Child[]
) {
return DropdownMenuRadioItemPrimitive(
{
...props,
"data-slot": "dropdown-menu-radio-item",
class: cn(
"group/menu-item relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
),
},
Span(
{
"data-slot": "menu-item-indicator",
class: "pointer-events-none absolute left-2 flex size-3.5 items-center justify-center",
},
CircleIcon({
"aria-hidden": true,
class: "size-2 hidden fill-current group-data-[state=checked]/menu-item:block",
}),
),
...children,
);
});
export type DropdownMenuGroupProps = ComponentProps<typeof DropdownMenuGroupPrimitive>;
export const DropdownMenuGroup = DropdownMenuGroupPrimitive;
export type DropdownMenuGroupHeadingProps = ComponentProps<
typeof DropdownMenuGroupHeadingPrimitive
>;
export const DropdownMenuGroupHeading = createComponent(function DropdownMenuGroupHeading(
{ class: className, ...props }: DropdownMenuGroupHeadingProps,
...children: Child[]
) {
return DropdownMenuGroupHeadingPrimitive(
{
...props,
"data-slot": "dropdown-menu-group-heading",
class: cn("px-2 py-1.5 text-xs font-medium text-muted-foreground", className),
},
...children,
);
});
export type DropdownMenuSeparatorProps = ComponentProps<typeof DropdownMenuSeparatorPrimitive>;
export const DropdownMenuSeparator = createComponent(function DropdownMenuSeparator({
class: className,
...props
}: DropdownMenuSeparatorProps) {
return DropdownMenuSeparatorPrimitive({
...props,
"data-slot": "dropdown-menu-separator",
class: cn("-mx-1 my-1 h-px bg-border", className),
});
});
export type DropdownMenuSubProps = ComponentProps<typeof DropdownMenuSubPrimitive>;
export const DropdownMenuSub = DropdownMenuSubPrimitive;
export type DropdownMenuSubTriggerProps = ComponentProps<typeof DropdownMenuSubTriggerPrimitive>;
export const DropdownMenuSubTrigger = createComponent(function DropdownMenuSubTrigger(
{ class: className, ...props }: DropdownMenuSubTriggerProps,
...children: Child[]
) {
return DropdownMenuSubTriggerPrimitive(
{
...props,
"data-slot": "dropdown-menu-sub-trigger",
class: cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
),
},
...children,
ChevronRightIcon({ "aria-hidden": true, class: "ml-auto size-4" }),
);
});
export type DropdownMenuSubContentProps = ComponentProps<typeof DropdownMenuSubContentPrimitive>;
export const DropdownMenuSubContent = createComponent(function DropdownMenuSubContent(
{ class: className, offset = 8, ...props }: DropdownMenuSubContentProps,
...children: Child[]
) {
return DropdownMenuSubContentPrimitive(
{
offset,
...props,
"data-slot": "dropdown-menu-sub-content",
// submenus pop in without a transition, so they feel instant
class: cn(
"absolute z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none",
"data-[state=open]:block",
"data-[state=closed]:pointer-events-none data-[state=closed]:hidden",
className,
),
},
...children,
);
});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.
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 |
|---|---|---|---|
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. |
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". 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.
| 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 | 4 | 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 its own check indicator, shown while the item is checked. Renders a Div; extra props are forwarded onto it.
| Prop | Type | Default | Description |
|---|---|---|---|
indicator | Child | — | 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. |
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 its own dot indicator, shown while the item is selected. 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. The styled trigger appends a chevron pointing into the submenu. 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
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.
| 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 | 8 | 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.