Dropdown Menu
A menu of actions hanging off a trigger 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"),
),
),
);
}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,
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";
// no overflow clipping: sub-content panels render nested inside and extend past this box
export const menuPanelBaseClasses =
"absolute z-50 min-w-[8rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none";
/** Panels that pop in without animation — submenus, which should feel instant. */
export const menuStaticPanelClasses = [
menuPanelBaseClasses,
"data-[state=open]:block",
"data-[state=closed]:pointer-events-none data-[state=closed]:hidden",
].join(" ");
export const menuContentClasses = [
menuPanelBaseClasses,
"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",
].join(" ");
export const menuItemClasses = [
"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",
].join(" ");
export const menuIndicatorItemClasses = "py-1.5 pr-2 pl-8";
export const menuIndicatorClasses =
"pointer-events-none absolute left-2 flex size-3.5 items-center justify-center";
export const menuGroupHeadingClasses = "px-2 py-1.5 text-xs font-medium text-muted-foreground";
export const menuSeparatorClasses = "-mx-1 my-1 h-px bg-border";
export const menuSubTriggerClasses =
"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground";
/** The chevron a sub trigger ends with. */
export function MenuSubTriggerChevron(): Child {
return ChevronRightIcon({ "aria-hidden": true, class: "ml-auto size-4" });
}
/** The check shown by checkbox items while checked. */
export function MenuCheckIndicator(): Child {
return Span(
{ "data-slot": "menu-item-indicator", class: menuIndicatorClasses },
CheckIcon({
"aria-hidden": true,
class: "size-4 hidden group-data-[state=checked]/menu-item:block",
}),
);
}
/** The dot shown by radio items while checked. */
export function MenuRadioIndicator(): Child {
return Span(
{ "data-slot": "menu-item-indicator", class: menuIndicatorClasses },
CircleIcon({
"aria-hidden": true,
class: "size-2 hidden fill-current group-data-[state=checked]/menu-item:block",
}),
);
}
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",
class: cn(
menuContentClasses,
"origin-(--ip-dropdown-menu-content-transform-origin)",
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(menuItemClasses, className) },
...children,
);
});
export type DropdownMenuCheckboxItemProps = ComponentProps<
typeof DropdownMenuCheckboxItemPrimitive
>;
export const DropdownMenuCheckboxItem = createComponent(function DropdownMenuCheckboxItem(
{ class: className, ...props }: DropdownMenuCheckboxItemProps,
...children: Child[]
) {
return DropdownMenuCheckboxItemPrimitive(
{
...props,
"data-slot": "dropdown-menu-checkbox-item",
class: cn("group/menu-item", menuItemClasses, menuIndicatorItemClasses, className),
},
MenuCheckIndicator(),
...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", menuItemClasses, menuIndicatorItemClasses, className),
},
MenuRadioIndicator(),
...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(menuGroupHeadingClasses, 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(menuSeparatorClasses, 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(menuItemClasses, menuSubTriggerClasses, className),
},
...children,
MenuSubTriggerChevron(),
);
});
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",
class: cn(menuStaticPanelClasses, 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"),
);
The shared menu styles
This file is the source of the menu look. menuContentClasses, menuItemClasses, menuGroupHeadingClasses, and the indicator helpers are exported and reused by the context menu, the menubar, and the select — so those three install this file alongside their own, and restyling every menu at once means editing one.
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. |
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 | Signal<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 | Signal<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 |
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 |
|---|---|---|---|
checked | Signal<boolean> | boolean | false | The checked state; selecting toggles it. Pass a signal to control it from outside. |
closeOnSelect | boolean | true | Whether selecting the item closes the menu. |
disabled | Signal<boolean> | boolean | false | Prevents selecting the item; the keyboard skips it. |
| Data attribute | Value |
|---|---|
[data-dropdown-menu-checkbox-item] | Present |
[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<string | null> | string | null | null | The checked item. Pass a signal to control it from outside. |
| 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* | string | — | Identifies the item. Must be unique within the radio group. |
closeOnSelect | boolean | true | Whether selecting the item closes the menu. |
disabled | Signal<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. |
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 | Signal<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.