Dropdown Menu
SaveA menu of actions opened from a button, built on Base UI Menu, with groups, separators, shortcuts, icons, and disabled items.
- Category
- Core
- Version
- 0.2.0
- Frameworks
- Next.js, Vite + React
- Styling
- Tailwind CSS v4
Preview
Dropdown Menu preview
Installation
npx @seamlessui/cli add dropdown-menuPre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add dropdown-menu --dry-runUsage
dropdown-menu-example.tsx
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/seamless/ui/dropdown-menu";
// Same recipe as the Button component's secondary variant, inlined so this example has no other dependency.
const button =
"group inline-flex h-9 items-center justify-center gap-1.5 rounded-lg border border-zinc-950/12 bg-white pr-2.5 pl-3.5 text-sm font-medium text-zinc-950 select-none " +
"transition-[background-color,border-color,scale] duration-150 ease-out active:duration-100 motion-safe:active:scale-[0.97] motion-reduce:transition-none " +
"hover:border-zinc-950/20 hover:bg-zinc-950/[0.03] data-[popup-open]:bg-zinc-950/[0.04] " +
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 " +
"dark:border-white/15 dark:bg-black dark:text-white dark:hover:border-white/25 dark:hover:bg-white/[0.06] dark:data-[popup-open]:bg-white/[0.06]";
const icon = { "aria-hidden": true, viewBox: "0 0 16 16", fill: "none" } as const;
const stroke = { stroke: "currentColor", strokeWidth: 1.5, strokeLinecap: "round", strokeLinejoin: "round" } as const;
export default function DropdownMenuExample() {
return (
<DropdownMenu>
<DropdownMenuTrigger className={button}>
Options
<svg
{...icon}
className="size-4 text-zinc-500 transition-[rotate] duration-160 ease-[cubic-bezier(0.23,1,0.32,1)] group-data-[popup-open]:rotate-180 motion-reduce:transition-none"
>
<path d="m4.5 6.25 3.5 3.5 3.5-3.5" {...stroke} />
</svg>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<svg {...icon}>
<path d="M9.5 3.5l3 3-7 7h-3v-3l7-7z" {...stroke} />
</svg>
Rename
<DropdownMenuShortcut>⌘R</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<svg {...icon}>
<rect x="5.5" y="5.5" width="8" height="8" rx="1.5" {...stroke} />
<path d="M10.5 3.5v-.25A.75.75 0 0 0 9.75 2.5h-6.5a.75.75 0 0 0-.75.75v6.5c0 .41.34.75.75.75h.25" {...stroke} />
</svg>
Duplicate
<DropdownMenuShortcut>⌘D</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Share</DropdownMenuLabel>
<DropdownMenuItem>
<svg {...icon}>
<path d="M7 9a2.5 2.5 0 0 0 3.5 0l2-2a2.5 2.5 0 0 0-3.5-3.5l-.5.5M9 7a2.5 2.5 0 0 0-3.5 0l-2 2A2.5 2.5 0 0 0 7 12.5l.5-.5" {...stroke} />
</svg>
Copy link
</DropdownMenuItem>
<DropdownMenuItem disabled>
<svg {...icon}>
<circle cx="8" cy="8" r="5.75" {...stroke} />
<path d="M2.5 8h11M8 2.25c1.5 1.6 2.25 3.5 2.25 5.75S9.5 12.15 8 13.75C6.5 12.15 5.75 10.25 5.75 8S6.5 3.85 8 2.25z" {...stroke} />
</svg>
Publish to web
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem destructive>
<svg {...icon}>
<path d="M3 4.5h10M6.5 4.5V3a.5.5 0 0 1 .5-.5h2a.5.5 0 0 1 .5.5v1.5M4.5 4.5l.6 8.1a1 1 0 0 0 1 .9h3.8a1 1 0 0 0 1-.9l.6-8.1" {...stroke} />
</svg>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| DropdownMenu open / defaultOpen / onOpenChange | boolean / boolean / (open, details) => void | — | Root state. Leave open unset for an uncontrolled menu. |
| DropdownMenu loopFocus | boolean | true | Wraps arrow-key focus from the last item to the first. |
| DropdownMenu modal | boolean | true | Locks page scroll and blocks outside pointer interaction while open. |
| DropdownMenuTrigger | Base UI Menu.Trigger props | — | Unstyled button that opens the menu. Pass className, or render={<Button />}. Sets data-popup-open while open. |
| DropdownMenuContent side / align | Side / "start" | "center" | "end" | "bottom" / "start" | Placement against the trigger. Flips when there is not enough room. |
| DropdownMenuContent sideOffset / alignOffset | number | 6 / 0 | Gap from the trigger and shift along its edge, in pixels. |
| DropdownMenuItem onClick | (event) => void | — | Runs the action. The menu closes afterwards unless closeOnClick is false. |
| DropdownMenuItem disabled | boolean | false | Keeps the item visible and reachable with the arrow keys, but ignores clicks and Enter. |
| DropdownMenuItem destructive | boolean | false | Red styling for delete-style actions. |
| DropdownMenuItem label | string | — | Text used for typeahead when the item content is not plain text. |
| DropdownMenuGroup / DropdownMenuLabel | Base UI Menu.Group / Menu.GroupLabel props | — | Groups items; the label must sit inside the group it names. |
| DropdownMenuSeparator | Base UI Separator props | — | Divider between groups. |
| DropdownMenuShortcut | ComponentProps<"span"> | — | Right-aligned keyboard shortcut hint inside an item, set in mono. |
Accessibility
- The trigger sets aria-haspopup and aria-expanded; the panel has role="menu" and items have role="menuitem".
- Enter, Space, or ArrowDown on the trigger opens the menu with focus on the first item; Arrow keys, Home, and End move between items.
- Typing a letter jumps to the next item starting with it (typeahead).
- Disabled items set aria-disabled and stay reachable with the arrow keys, so screen reader users know the action exists; activating them does nothing.
- Escape or choosing an item closes the menu and returns focus to the trigger.
- Groups are labelled with aria-labelledby from DropdownMenuLabel; separators have role="separator".
- The highlighted item changes background and text color instantly (no animated highlight, so arrow-key movement never lags). The panel grows from the trigger in 160ms and fades out in 110ms; with reduced motion the transitions are removed.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/ui/dropdown-menu.tsxdropdown-menu- npm packages: @base-ui/react@^1.9.0React and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/dropdown-menu/0.2.0.json.
ui/dropdown-menu.tsxShowHide
ui/dropdown-menu.tsx
"use client";
import { Menu as BaseMenu } from "@base-ui/react/menu";
import type { ComponentProps } from "react";
type ClassName<State> = string | ((state: State) => string | undefined) | undefined;
function cx(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(" ");
}
/** Prepends base classes while keeping Base UI's state-function form of className working. */
function withBase<State>(base: string, className: ClassName<State>) {
return typeof className === "function" ? (state: State) => cx(base, className(state)) : cx(base, className);
}
/** Groups the menu parts and holds its open state. Pass `open`/`onOpenChange` to control it. */
export const DropdownMenu = BaseMenu.Root;
/** A button that opens the menu. Unstyled; pass `className`, or `render={<Button />}` to reuse your button. */
export const DropdownMenuTrigger = BaseMenu.Trigger;
/** Groups related items. Put a DropdownMenuLabel inside to name the group. */
export const DropdownMenuGroup = BaseMenu.Group;
type PositionerProps = BaseMenu.Positioner.Props;
export interface DropdownMenuContentProps extends BaseMenu.Popup.Props {
/** Side of the trigger to place the menu on. Flips when there is not enough room. */
side?: PositionerProps["side"];
/** Alignment against the trigger on the chosen side. */
align?: PositionerProps["align"];
/** Gap between the trigger and the menu, in pixels. */
sideOffset?: PositionerProps["sideOffset"];
/** Shift along the trigger edge, in pixels. */
alignOffset?: PositionerProps["alignOffset"];
/** Classes for the positioner wrapper, for example to change the z-index. */
positionerClassName?: string;
/** Element to portal into. Defaults to `document.body`. */
container?: BaseMenu.Portal.Props["container"];
}
/** The menu panel, rendered in a portal and positioned against the trigger. */
export function DropdownMenuContent({
side = "bottom",
align = "start",
sideOffset = 6,
alignOffset = 0,
positionerClassName,
container,
className,
...props
}: DropdownMenuContentProps) {
return (
<BaseMenu.Portal container={container}>
<BaseMenu.Positioner
side={side}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cx("z-50 outline-none", positionerClassName)}
>
<BaseMenu.Popup
className={withBase(
cx(
// 12px panel radius minus 6px padding gives the items their concentric 6px radius.
"max-h-(--available-height) min-w-52 origin-(--transform-origin) overflow-y-auto overscroll-contain rounded-xl p-1.5 outline-none",
"border border-zinc-950/10 bg-white text-zinc-950 shadow-[0_12px_32px_-8px_rgb(0_0_0/0.18)]",
"dark:border-white/15 dark:bg-black dark:text-white dark:shadow-[0_16px_48px_-12px_rgb(0_0_0/1),inset_0_1px_0_rgb(255_255_255/0.08)]",
// Enter: 160ms from 96% at the trigger. Exit: 110ms, fades with a smaller scale.
"transition-[opacity,scale] duration-160 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
"data-[starting-style]:opacity-0 motion-safe:data-[starting-style]:scale-96",
"data-[ending-style]:opacity-0 data-[ending-style]:duration-110 data-[ending-style]:ease-out motion-safe:data-[ending-style]:scale-98",
),
className,
)}
{...props}
/>
</BaseMenu.Positioner>
</BaseMenu.Portal>
);
}
export interface DropdownMenuItemProps extends BaseMenu.Item.Props {
/** Styles the item for actions that delete or cannot be undone. */
destructive?: boolean;
}
/**
* An action in the menu. Use `onClick` to handle it; `disabled` keeps it visible but skips it.
* The highlight follows the pointer and arrow keys instantly; menus are used too often to animate it.
*/
export function DropdownMenuItem({ destructive = false, className, ...props }: DropdownMenuItemProps) {
return (
<BaseMenu.Item
data-destructive={destructive || undefined}
className={withBase(
cx(
"flex h-8 cursor-default items-center gap-2.5 rounded-md px-2 text-sm outline-none select-none",
"[&_svg]:size-4 [&_svg]:shrink-0",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-45",
destructive
? cx(
"text-red-600 [&_svg]:text-red-600/80 data-[highlighted]:bg-red-500/[0.08] data-[highlighted]:text-red-700",
"dark:text-red-400 dark:[&_svg]:text-red-400/80 dark:data-[highlighted]:bg-red-500/[0.12] dark:data-[highlighted]:text-red-300",
)
: cx(
"text-zinc-700 [&_svg]:text-zinc-500 data-[highlighted]:bg-zinc-950/[0.05] data-[highlighted]:text-zinc-950 data-[highlighted]:[&_svg]:text-zinc-950",
"dark:text-zinc-300 dark:[&_svg]:text-zinc-500 dark:data-[highlighted]:bg-white/[0.08] dark:data-[highlighted]:text-white dark:data-[highlighted]:[&_svg]:text-white",
),
),
className,
)}
{...props}
/>
);
}
/** Names a DropdownMenuGroup. Must be rendered inside the group it labels. */
export function DropdownMenuLabel({ className, ...props }: BaseMenu.GroupLabel.Props) {
return (
<BaseMenu.GroupLabel
className={withBase("px-2 pt-2 pb-1 text-xs font-medium text-zinc-500 select-none", className)}
{...props}
/>
);
}
/** A divider between groups of items. */
export function DropdownMenuSeparator({ className, ...props }: ComponentProps<typeof BaseMenu.Separator>) {
return (
<BaseMenu.Separator
className={withBase("-mx-1.5 my-1.5 h-px bg-zinc-950/[0.07] dark:bg-white/[0.08]", className)}
{...props}
/>
);
}
/** A keyboard shortcut hint shown at the end of an item. */
export function DropdownMenuShortcut({ className, ...props }: ComponentProps<"span">) {
return (
<span
className={cx(
"ml-auto pl-6 font-mono text-xs text-zinc-400 tabular-nums dark:text-zinc-500",
"in-data-[highlighted]:text-zinc-500 dark:in-data-[highlighted]:text-zinc-400",
className,
)}
{...props}
/>
);
}