---
name: dropdown-menu
type: core
domain: overlays
requires: [loke-ui]
description: >
  Trigger-based dropdown menus. DropdownMenu, DropdownMenuTrigger, DropdownMenuPortal,
  DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem,
  DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSub,
  DropdownMenuSubTrigger, DropdownMenuSubContent. modal=true default.
  onSelect fires and auto-closes; onClick does not auto-close. Typeahead requires textValue for icon items.
  CSS vars: --loke-dropdown-menu-content-available-height, -width, -transform-origin,
  --loke-dropdown-menu-trigger-height, -width.
---

# Dropdown Menu

## Setup

Basic menu with portal, items, separator, and group.

```tsx
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuPortal,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@loke/ui/dropdown-menu";

function Example() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger>Actions</DropdownMenuTrigger>
      <DropdownMenuPortal>
        <DropdownMenuContent>
          <DropdownMenuGroup>
            <DropdownMenuLabel>File</DropdownMenuLabel>
            <DropdownMenuItem onSelect={() => console.log("new")}>
              New
            </DropdownMenuItem>
            <DropdownMenuItem onSelect={() => console.log("open")}>
              Open
            </DropdownMenuItem>
          </DropdownMenuGroup>
          <DropdownMenuSeparator />
          <DropdownMenuItem onSelect={() => console.log("delete")}>
            Delete
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenuPortal>
    </DropdownMenu>
  );
}
```

`DropdownMenu` defaults to `modal={true}`. `DropdownMenuPortal` renders into `document.body`. Arrow keys navigate items, typeahead jumps to matching items by first character.

## Core Patterns

### Checkbox items

```tsx
import { useState } from "react";
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuItemIndicator,
  DropdownMenuPortal,
  DropdownMenuTrigger,
} from "@loke/ui/dropdown-menu";

function ViewOptions() {
  const [showGrid, setShowGrid] = useState(false);
  const [showRulers, setShowRulers] = useState(true);

  return (
    <DropdownMenu>
      <DropdownMenuTrigger>View</DropdownMenuTrigger>
      <DropdownMenuPortal>
        <DropdownMenuContent>
          <DropdownMenuCheckboxItem
            checked={showGrid}
            onCheckedChange={setShowGrid}
          >
            <DropdownMenuItemIndicator>✓</DropdownMenuItemIndicator>
            Show grid
          </DropdownMenuCheckboxItem>
          <DropdownMenuCheckboxItem
            checked={showRulers}
            onCheckedChange={setShowRulers}
          >
            <DropdownMenuItemIndicator>✓</DropdownMenuItemIndicator>
            Show rulers
          </DropdownMenuCheckboxItem>
        </DropdownMenuContent>
      </DropdownMenuPortal>
    </DropdownMenu>
  );
}
```

`DropdownMenuItemIndicator` renders only when the item is checked. Style it with `data-state="checked"` / `data-state="unchecked"`.

### Radio items

```tsx
import { useState } from "react";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItemIndicator,
  DropdownMenuPortal,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@loke/ui/dropdown-menu";

function SortMenu() {
  const [sort, setSort] = useState("name");

  return (
    <DropdownMenu>
      <DropdownMenuTrigger>Sort by</DropdownMenuTrigger>
      <DropdownMenuPortal>
        <DropdownMenuContent>
          <DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
            <DropdownMenuRadioItem value="name">
              <DropdownMenuItemIndicator>•</DropdownMenuItemIndicator>
              Name
            </DropdownMenuRadioItem>
            <DropdownMenuRadioItem value="date">
              <DropdownMenuItemIndicator>•</DropdownMenuItemIndicator>
              Date modified
            </DropdownMenuRadioItem>
            <DropdownMenuRadioItem value="size">
              <DropdownMenuItemIndicator>•</DropdownMenuItemIndicator>
              Size
            </DropdownMenuRadioItem>
          </DropdownMenuRadioGroup>
        </DropdownMenuContent>
      </DropdownMenuPortal>
    </DropdownMenu>
  );
}
```

### Submenus

```tsx
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuPortal,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
  DropdownMenuTrigger,
} from "@loke/ui/dropdown-menu";

function MenuWithSubmenu() {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger>Options</DropdownMenuTrigger>
      <DropdownMenuPortal>
        <DropdownMenuContent>
          <DropdownMenuItem onSelect={() => {}}>Cut</DropdownMenuItem>
          <DropdownMenuItem onSelect={() => {}}>Copy</DropdownMenuItem>
          <DropdownMenuSub>
            <DropdownMenuSubTrigger>Share</DropdownMenuSubTrigger>
            <DropdownMenuPortal>
              <DropdownMenuSubContent>
                <DropdownMenuItem onSelect={() => {}}>Email</DropdownMenuItem>
                <DropdownMenuItem onSelect={() => {}}>Slack</DropdownMenuItem>
              </DropdownMenuSubContent>
            </DropdownMenuPortal>
          </DropdownMenuSub>
        </DropdownMenuContent>
      </DropdownMenuPortal>
    </DropdownMenu>
  );
}
```

`DropdownMenuSubContent` must also be wrapped in `DropdownMenuPortal` to avoid stacking and clipping issues.

## Common Mistakes

### Using onClick instead of onSelect

`DropdownMenuItem` fires a `menu.itemSelect` custom event that calls `onSelect` and then auto-closes the menu. `onClick` does not trigger the close sequence.

```tsx
// Wrong — menu stays open after click
<DropdownMenuItem onClick={() => doAction()}>
  Action
</DropdownMenuItem>

// Correct — menu closes after selection
<DropdownMenuItem onSelect={() => doAction()}>
  Action
</DropdownMenuItem>
```

To prevent auto-close on selection (e.g., for a checkbox item you want to stay open), call `event.preventDefault()` inside `onSelect`.

Source: `src/components/menu/menu.tsx` — `MenuItem` dispatches `menu.itemSelect` custom event.

### Forgetting DropdownMenuPortal around DropdownMenuContent

Without a portal, the menu content renders in the DOM flow. It will be clipped by `overflow: hidden` ancestors and may appear behind other elements due to z-index stacking.

```tsx
// Wrong — may be clipped or obscured
<DropdownMenu>
  <DropdownMenuTrigger>Actions</DropdownMenuTrigger>
  <DropdownMenuContent>...</DropdownMenuContent>
</DropdownMenu>

// Correct
<DropdownMenu>
  <DropdownMenuTrigger>Actions</DropdownMenuTrigger>
  <DropdownMenuPortal>
    <DropdownMenuContent>...</DropdownMenuContent>
  </DropdownMenuPortal>
</DropdownMenu>
```

Source: `src/components/dropdown-menu/dropdown-menu.tsx`.

### Not providing textValue for items with non-text children

Menu typeahead reads `textContent` from each item to match keystrokes. If an item contains icons, badges, or other non-text nodes, `textContent` includes the icon's text nodes and typeahead matches incorrectly or not at all.

```tsx
// Wrong — typeahead reads "Delete" plus any icon text
<DropdownMenuItem>
  <TrashIcon /> Delete
</DropdownMenuItem>

// Correct — typeahead uses explicit value
<DropdownMenuItem textValue="Delete">
  <TrashIcon /> Delete
</DropdownMenuItem>
```

Source: `src/components/menu/menu.tsx` — typeahead uses `textContent` by default.

## Cross-references

- See also: [references/menu-components.md](references/menu-components.md) — full sub-component prop reference
- See also: [popover](../popover/SKILL.md) — for custom floating panels that aren't menus
- See also: [choosing-the-right-component](../choosing-the-right-component/SKILL.md) — DropdownMenu vs Select decision
