Skip to content
Seamless UI

Dropdown Menu

Save

A 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-menu

Pre-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-run

Usage

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

PropTypeDefaultDescription
DropdownMenu open / defaultOpen / onOpenChangeboolean / boolean / (open, details) => void—Root state. Leave open unset for an uncontrolled menu.
DropdownMenu loopFocusbooleantrueWraps arrow-key focus from the last item to the first.
DropdownMenu modalbooleantrueLocks page scroll and blocks outside pointer interaction while open.
DropdownMenuTriggerBase UI Menu.Trigger props—Unstyled button that opens the menu. Pass className, or render={<Button />}. Sets data-popup-open while open.
DropdownMenuContent side / alignSide / "start" | "center" | "end""bottom" / "start"Placement against the trigger. Flips when there is not enough room.
DropdownMenuContent sideOffset / alignOffsetnumber6 / 0Gap 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 disabledbooleanfalseKeeps the item visible and reachable with the arrow keys, but ignores clicks and Enter.
DropdownMenuItem destructivebooleanfalseRed styling for delete-style actions.
DropdownMenuItem labelstring—Text used for typeahead when the item content is not plain text.
DropdownMenuGroup / DropdownMenuLabelBase UI Menu.Group / Menu.GroupLabel props—Groups items; the label must sit inside the group it names.
DropdownMenuSeparatorBase UI Separator props—Divider between groups.
DropdownMenuShortcutComponentProps<"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.tsxShow
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}
    />
  );
}