Dropdown Menu

A dropdown menu is a list of actions that opens from a button. It keeps rarely used commands one click away without giving each one a button on the page.

import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuShortcut,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";

export default function DropdownMenuDemo() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger render={<Button variant="secondary" />}>
        Account
        <ChevronDown />
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuGroup>
          <DropdownMenuLabel>My account</DropdownMenuLabel>
          <DropdownMenuItem>
            Profile
            <DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
          </DropdownMenuItem>
          <DropdownMenuItem>
            Settings
            <DropdownMenuShortcut>⌘,</DropdownMenuShortcut>
          </DropdownMenuItem>
          <DropdownMenuItem disabled>Billing</DropdownMenuItem>
        </DropdownMenuGroup>
        <DropdownMenuSeparator />
        <DropdownMenuCheckboxItem defaultChecked>Show status bar</DropdownMenuCheckboxItem>
        <DropdownMenuSeparator />
        <DropdownMenuRadioGroup defaultValue="system">
          <DropdownMenuRadioItem value="light">Light</DropdownMenuRadioItem>
          <DropdownMenuRadioItem value="dark">Dark</DropdownMenuRadioItem>
          <DropdownMenuRadioItem value="system">System</DropdownMenuRadioItem>
        </DropdownMenuRadioGroup>
        <DropdownMenuSeparator />
        <DropdownMenuItem variant="destructive">Log out</DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

The code

The component is a single file, read from the repository when the site is built. Press Show code to see all of it.

import type { ComponentProps } from "react";
import { Check } from "lucide-react";
import { Menu as BaseMenu } from "@base-ui/react/menu";
import { cx } from "@/lib/cx";
import { menuItemStyles, menuItemTones, popupMotion, popupSurface } from "@/lib/styles";
import type { WithClass } from "@/lib/types";

export const DropdownMenu = BaseMenu.Root;

// Renders a button by default. To open it from your own element, such as a Button, pass it as
// render: <DropdownMenuTrigger render={<Button />}>.
export const DropdownMenuTrigger = BaseMenu.Trigger;

// side, sideOffset and align place the menu against its trigger; every other prop goes to the
// popup itself.
export function DropdownMenuContent({
  side = "bottom",
  sideOffset = 6,
  align = "start",
  className,
  ...props
}: WithClass<ComponentProps<typeof BaseMenu.Popup>> &
  Pick<ComponentProps<typeof BaseMenu.Positioner>, "side" | "sideOffset" | "align">) {
  return (
    <BaseMenu.Portal>
      <BaseMenu.Positioner side={side} sideOffset={sideOffset} align={align} className="z-50">
        <BaseMenu.Popup className={cx(popupSurface, popupMotion, "min-w-48 p-1", className)} {...props} />
      </BaseMenu.Positioner>
    </BaseMenu.Portal>
  );
}

// variant="destructive" turns the row red, for something like Log out or Delete.
export function DropdownMenuItem({
  variant = "default",
  className,
  ...props
}: WithClass<ComponentProps<typeof BaseMenu.Item>> & { variant?: keyof typeof menuItemTones }) {
  return <BaseMenu.Item className={cx(menuItemStyles, menuItemTones[variant], className)} {...props} />;
}

export function DropdownMenuSeparator({ className, ...props }: WithClass<ComponentProps<typeof BaseMenu.Separator>>) {
  return <BaseMenu.Separator className={cx("-mx-1 my-1 h-px bg-border", className)} {...props} />;
}

// A group with a label. Put DropdownMenuLabel inside a DropdownMenuGroup.
export const DropdownMenuGroup = BaseMenu.Group;

export function DropdownMenuLabel({ className, ...props }: WithClass<ComponentProps<typeof BaseMenu.GroupLabel>>) {
  return <BaseMenu.GroupLabel className={cx("px-2 py-1.5 text-sm font-medium text-text-secondary", className)} {...props} />;
}

// A row that can be ticked on and off. The tick shows in the space on the left.
export function DropdownMenuCheckboxItem({
  className,
  children,
  ...props
}: WithClass<ComponentProps<typeof BaseMenu.CheckboxItem>>) {
  return (
    <BaseMenu.CheckboxItem className={cx(menuItemStyles, menuItemTones.default, "relative pl-8", className)} {...props}>
      <span className="absolute left-2 flex size-4 items-center justify-center">
        <BaseMenu.CheckboxItemIndicator>
          <Check aria-hidden className="size-4" />
        </BaseMenu.CheckboxItemIndicator>
      </span>
      {children}
    </BaseMenu.CheckboxItem>
  );
}

// Radio rows, where exactly one is chosen. Wrap the DropdownMenuRadioItems in a
// DropdownMenuRadioGroup.
export const DropdownMenuRadioGroup = BaseMenu.RadioGroup;

export function DropdownMenuRadioItem({
  className,
  children,
  ...props
}: WithClass<ComponentProps<typeof BaseMenu.RadioItem>>) {
  return (
    <BaseMenu.RadioItem className={cx(menuItemStyles, menuItemTones.default, "relative pl-8", className)} {...props}>
      <span className="absolute left-2 flex size-4 items-center justify-center">
        <BaseMenu.RadioItemIndicator>
          <span className="block size-2 rounded-full bg-text" />
        </BaseMenu.RadioItemIndicator>
      </span>
      {children}
    </BaseMenu.RadioItem>
  );
}

// A keyboard shortcut at the right end of a row, such as <Kbd> or plain text.
export function DropdownMenuShortcut({ className, ...props }: ComponentProps<"span">) {
  return <span className={cx("ml-auto pl-6 text-sm text-text-secondary", className)} {...props} />;
}

Props

PropTypeDefaultWhat it does
variant"default" | "destructive""default"On DropdownMenuItem. The destructive variant turns the row red, for something like Log out or Delete.
onClickfunction—On DropdownMenuItem. Runs when the item is chosen. The menu closes afterwards.
disabledbooleanfalseOn any item. Dims it and stops it from being chosen. The arrow keys can still reach it.
defaultCheckedboolean—On DropdownMenuCheckboxItem. Whether it starts ticked. Use checked and onCheckedChange to control it.
defaultValuestring—On DropdownMenuRadioGroup. The value of the radio item that starts chosen. Each DropdownMenuRadioItem needs a value.
side"top" | "right" | "bottom" | "left""bottom"On DropdownMenuContent. Which side of the button it opens on. It flips to the other side if there is no room.
align"start" | "center" | "end""start"On DropdownMenuContent. How it lines up along that side.
renderelement—On DropdownMenuTrigger. Opens the menu from your own element, such as a Button.

Every other prop goes to the Base UI Menu underneath.

The thinking

A menu answers the question: what can I do here? It lists actions, so choosing a row does something and the menu goes away.

  • Built on Base UI: Base UI handles the open state, the placement, the arrow-key navigation and type-ahead, and it closes the menu when an item is chosen.
  • One row style: The Dropdown Menu and the Context Menu share one look for their rows, so a command looks the same wherever you meet it.
  • Row types: A plain item runs an action. A checkbox item switches something on or off. Radio items choose one of several. Groups and separators keep a long menu readable.
  • Menu or Select: A menu runs commands. A Select picks a value for a form field.

Interface design

  • Surface: The shared overlay surface with 4px of padding, so a highlighted row sits inside the border instead of touching it.
  • Rows: Each row has the sm radius and a light wash when highlighted, whether by the pointer or the keyboard.
  • Marks on the left: Ticks and radio dots sit in a column on the left. Shortcuts sit at the right end of the row.
  • Destructive: Red text and a red wash, so a dangerous row can be told apart before reading it.
  • Disabled: The row text uses the muted color and the not-allowed cursor.

Interaction

  • Open: Click the button, or press Enter, Space or the Down arrow when it has focus.
  • Keyboard: The arrow keys move through the rows, Home and End jump to the first and last, and typing a letter jumps to a row that starts with it. Enter or Space chooses a row.
  • Close: Escape, a click outside, or choosing an item. Checkbox and radio items keep it open until you leave.
  • Focus: When the menu closes, focus goes back to the button.

Empathy

  • Announced as a menu: The button reports that it opens a menu, and each row is announced with its role. Checkbox and radio rows report whether they are on.
  • Not only a pointer thing: Everything works with the keyboard alone.
  • Don't hide the main action: Put the thing most people came to do on the page. A menu is for the rest.
  • Words, not only icons: Rows are labeled with text so they can be found by reading, searching and typing.