---
title: DropdownMenu
description: Open a compact action menu from a visible trigger.
package: moraine
version: 0.6.0
repository: https://github.com/subframe7536/moraine
---

# DropdownMenu

> Open a compact action menu from a visible trigger.

Use DropdownMenu for a compact set of actions opened from a visible trigger. Use [Select](https://moraine.subf.dev/components/select.md) for choosing a form value.

## Basic usage

```tsx
import { Button, DropdownMenu } from 'moraine'

export function Example() {
  return (
    <DropdownMenu>
      <DropdownMenu.Trigger as={Button}>Actions</DropdownMenu.Trigger>
      <DropdownMenu.Content items={[{ label: 'Copy' }]} />
    </DropdownMenu>
  )
}
```

## Anatomy

```text
DropdownMenu [component; no DOM]
├── DropdownMenu.Trigger [part; slot=trigger]
├── overlay [slot]
└── positioner [internal]
    └── DropdownMenu.Content [part; slot=content]
        ├── group [slot]
        │   ├── groupLabel [slot]
        │   └── item [slot]
        ├── item [slot]
        │   ├── itemLeading [slot]
        │   ├── itemWrapper [slot]
        │   │   ├── itemLabel [slot]
        │   │   └── itemDescription [slot]
        │   └── itemTrailing [slot]
        │       ├── itemKbds [slot]
        │       ├── itemIndicator [slot]
        │       └── itemSubIndicator [slot]
        └── separator [slot]
```

Content is portaled; grouped items use the same item subtree as ungrouped items.

## Usage

### Trigger and items

Use `DropdownMenu.Trigger` to compose the click, keyboard, and ARIA behavior with your native attributes. Describe each action in the item model; groups and separators organize that model without adding application state.

```tsx
import { Button, DropdownMenu } from 'moraine'

const ACTIONS = [
  { label: 'Edit profile', leading: 'i-lucide:user' },
  { label: 'Billing details', leading: 'i-lucide:credit-card' },
  { label: 'Keyboard shortcuts', leading: 'i-lucide:keyboard' },
]

export function TriggerItems() {
  return (
    <DropdownMenu>
      <DropdownMenu.Trigger as={Button} variant="outline" trailing="i-lucide:chevron-down">
        Account options
      </DropdownMenu.Trigger>
      <DropdownMenu.Content items={ACTIONS} />
    </DropdownMenu>
  )
}
```

### Stateful and nested items

Checkbox and radio-like menu items receive their checked or selected state from your application through their callbacks. Nested children create submenus. Disabled items remain visible but cannot be activated.

```tsx
import { Button, DropdownMenu } from 'moraine'
import type { DropdownMenuT } from 'moraine'
import { createMemo, createSignal } from 'solid-js'

export function StatefulItems() {
  const [showBookmarks, setShowBookmarks] = createSignal(true)
  const [theme, setTheme] = createSignal('system')

  const items = createMemo<DropdownMenuT.Item[]>(() => [
    {
      type: 'checkbox',
      label: 'Show bookmarks bar',
      checked: showBookmarks(),
      onCheckedChange: setShowBookmarks,
    },
    { type: 'separator' },
    {
      label: 'Theme',
      children: [
        {
          type: 'radio',
          group: 'theme',
          label: 'Light',
          value: 'light',
          checked: theme() === 'light',
          onValueChange: setTheme,
        },
        {
          type: 'radio',
          group: 'theme',
          label: 'Dark',
          value: 'dark',
          checked: theme() === 'dark',
          onValueChange: setTheme,
        },
        {
          type: 'radio',
          group: 'theme',
          label: 'System',
          value: 'system',
          checked: theme() === 'system',
          onValueChange: setTheme,
        },
      ],
    },
  ])

  return (
    <DropdownMenu>
      <DropdownMenu.Trigger as={Button} variant="outline">
        View preferences
      </DropdownMenu.Trigger>
      <DropdownMenu.Content items={items()} />
    </DropdownMenu>
  )
}
```

### Keyboard interaction

| Key                                                        | Description                                           |
| ---------------------------------------------------------- | ----------------------------------------------------- |
| <kbd>Enter</kbd> / <kbd>Space</kbd> / <kbd>ArrowDown</kbd> | Opens the menu from an eligible trigger interaction.  |
| <kbd>ArrowDown</kbd> / <kbd>ArrowUp</kbd>                  | Moves the active item through available menu entries. |
| <kbd>ArrowRight</kbd>                                      | Opens an available submenu from its parent item.      |
| <kbd>ArrowLeft</kbd>                                       | Returns from a submenu to its parent menu.            |
| <kbd>Enter</kbd> / <kbd>Space</kbd>                        | Activates the current menu item.                      |
| <kbd>Escape</kbd>                                          | Closes the current menu layer.                        |

## Examples

### Workspace view settings

Toggle visible fields, include archived projects, and choose a sort order. The preview updates from application-owned state; saving a shared view is disabled for viewers.

```tsx
import { Button, DropdownMenu } from 'moraine'
import type { DropdownMenuT } from 'moraine'
import { createMemo, createSignal, For, Show } from 'solid-js'

const PROJECTS = [
  { name: 'Design system', owner: 'Alex', updated: 3, archived: false },
  { name: 'Customer portal', owner: 'Sam', updated: 2, archived: false },
  { name: 'Website v1', owner: 'Jordan', updated: 1, archived: true },
]

export function WorkspaceView() {
  const [showArchived, setShowArchived] = createSignal(false)
  const [showOwner, setShowOwner] = createSignal(true)
  const [sort, setSort] = createSignal('updated')
  const projects = createMemo(() => {
    const includeArchived = showArchived()
    const byName = sort() === 'name'
    return PROJECTS.filter((project) => includeArchived || !project.archived).sort((a, b) =>
      byName ? a.name.localeCompare(b.name) : b.updated - a.updated,
    )
  })
  const items = createMemo<DropdownMenuT.Item[]>(() => [
    {
      type: 'group',
      label: 'Visible fields',
      children: [
        {
          type: 'checkbox',
          label: 'Project owner',
          checked: showOwner(),
          onCheckedChange: setShowOwner,
        },
        {
          type: 'checkbox',
          label: 'Archived projects',
          checked: showArchived(),
          onCheckedChange: setShowArchived,
        },
      ],
    },
    { type: 'separator' },
    {
      label: 'Sort projects',
      icon: 'i-lucide:arrow-down-wide-narrow',
      children: [
        {
          type: 'radio',
          group: 'sort',
          label: 'Recently updated',
          value: 'updated',
          checked: sort() === 'updated',
          onValueChange: setSort,
        },
        {
          type: 'radio',
          group: 'sort',
          label: 'Name',
          value: 'name',
          checked: sort() === 'name',
          onValueChange: setSort,
        },
      ],
    },
    { type: 'separator' },
    {
      label: 'Reset view',
      icon: 'i-lucide:rotate-ccw',
      onSelect: () => {
        setShowArchived(false)
        setShowOwner(true)
        setSort('updated')
      },
    },
    {
      label: 'Save for everyone',
      description: 'Workspace administrator permission required',
      disabled: true,
      icon: 'i-lucide:lock',
    },
  ])

  return (
    <section
      class="p-4 border border-border rounded-lg bg-card max-w-lg w-full space-y-4"
      aria-label="Workspace projects"
    >
      <div class="flex flex-wrap gap-3 items-center justify-between">
        <div>
          <h4 class="text-sm font-semibold">Workspace projects</h4>
          <p class="text-xs text-muted-foreground">
            Customize this view without changing project data.
          </p>
        </div>
        <DropdownMenu>
          <DropdownMenu.Trigger as={Button} variant="outline" leading="i-lucide:sliders-horizontal">
            View
          </DropdownMenu.Trigger>
          <DropdownMenu.Content items={items()} />
        </DropdownMenu>
      </div>
      <ul class="divide-border divide-y">
        <For each={projects()}>
          {(project) => (
            <li class="text-sm py-3 flex gap-3 items-center justify-between">
              <span>
                {project.name}
                <Show when={project.archived}>
                  <span class="text-xs text-muted-foreground ms-2">Archived</span>
                </Show>
              </span>
              <Show when={showOwner()}>
                <span class="text-xs text-muted-foreground">{project.owner}</span>
              </Show>
            </li>
          )}
        </For>
      </ul>
      <p role="status" class="text-xs text-muted-foreground">
        {projects().length} projects · Sorted by {sort() === 'name' ? 'name' : 'recently updated'}
      </p>
    </section>
  )
}
```

### Checkbox and radio items

```tsx
import { Button, DropdownMenu } from 'moraine'
import type { DropdownMenuT } from 'moraine'
import { createMemo, createSignal } from 'solid-js'

export function CheckboxRadio() {
  const [showArchived, setShowArchived] = createSignal(false)
  const [layout, setLayout] = createSignal('grid')
  const items = createMemo<DropdownMenuT.Item[]>(() => [
    {
      type: 'checkbox',
      label: 'Show archived',
      checked: showArchived(),
      onCheckedChange: setShowArchived,
    },
    { type: 'separator' },
    {
      type: 'radio',
      label: 'Grid',
      group: 'layout',
      value: 'grid',
      checked: layout() === 'grid',
      onValueChange: setLayout,
    },
    {
      type: 'radio',
      label: 'List',
      group: 'layout',
      value: 'list',
      checked: layout() === 'list',
      onValueChange: setLayout,
    },
  ])

  return (
    <div class="flex gap-3 items-center">
      <DropdownMenu>
        <DropdownMenu.Trigger as={Button} variant="outline">
          View options
        </DropdownMenu.Trigger>
        <DropdownMenu.Content items={items()} />
      </DropdownMenu>
      <p class="text-sm text-muted-foreground">
        {layout()} layout, archived: {String(showArchived())}
      </p>
    </div>
  )
}
```

### Submenus

```tsx
import { Button, DropdownMenu } from 'moraine'

export function Submenus() {
  return (
    <DropdownMenu>
      <DropdownMenu.Trigger as={Button} variant="outline">
        File
      </DropdownMenu.Trigger>
      <DropdownMenu.Content
        items={[
          {
            label: 'Move to…',
            icon: 'i-lucide:folder-input',
            children: [
              {
                type: 'group',
                label: 'Folders',
                children: [
                  { label: 'Inbox', icon: 'i-lucide:inbox' },
                  { label: 'Archive', icon: 'i-lucide:archive' },
                ],
              },
            ],
          },
          { label: 'Download', icon: 'i-lucide:download' },
        ]}
      />
    </DropdownMenu>
  )
}
```

### Shortcuts and disabled items

```tsx
import { Button, DropdownMenu } from 'moraine'

export function ShortcutsDisabled() {
  return (
    <DropdownMenu>
      <DropdownMenu.Trigger as={Button} variant="outline">
        Share
      </DropdownMenu.Trigger>
      <DropdownMenu.Content
        items={[
          { label: 'Copy link', icon: 'i-lucide:link', kbds: ['⌘', 'C'] },
          { label: 'Export', icon: 'i-lucide:download', kbds: ['⌘', 'E'], disabled: true },
        ]}
      />
    </DropdownMenu>
  )
}
```

## Attributes

| Attributes | Slot | Description |
| --- | --- | --- |
| `data-closed` | `dropdown-menu-trigger`, `dropdown-menu-content` | Present when disclosure or transition content is closed. |
| `data-disabled` | `dropdown-menu-trigger`, `dropdown-menu-item` | Present when the component, slot, or item is disabled. |
| `data-expanded` | `dropdown-menu-trigger`, `dropdown-menu-content`, `dropdown-menu-item` | Present when the panel, accordion, or menu is expanded. |
| `data-align` | `dropdown-menu-content` | Stores the resolved alignment of positioned content. |
| `data-side` | `dropdown-menu-content` | Stores the resolved floating or drawer content side. |
| `data-destructive` | `dropdown-menu-item` | Present when the action has destructive semantics. |
| `data-highlighted` | `dropdown-menu-item` | Present when the item is highlighted by pointer or keyboard navigation. |
| `data-selected` | `dropdown-menu-item` | Present when the item or tab is selected. |

## Props

### DropdownMenu

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| align | 'start' \| 'center' \| 'end' \| undefined | 'start' | Alignment along the cross axis. |
| defaultOpen | boolean \| undefined | false | Initial open state when the component is uncontrolled. |
| disabled | boolean \| undefined | false | Whether trigger interactions should be ignored. |
| gutter | number \| undefined | 0 | Gap between the anchor and the content. |
| id | string \| undefined | — | Unique base id used to derive trigger and content ids. |
| onOpenChange | ((open: boolean) => void) \| undefined | — | Called whenever the menu requests an open state change. |
| open | boolean \| undefined | — | Controlled open state of the menu. |
| overflowPadding | number \| undefined | 4 | Padding applied to the overflow area when calculating the menu's position. |
| placement | 'top' \| 'right' \| 'bottom' \| 'left' \| undefined | 'bottom' | Preferred side relative to the anchor. |
| preventScroll | boolean \| undefined | true | Whether body scroll should be locked while the menu is open. |
| shift | number \| undefined | 0 | Cross-axis or alignment offset relative to the anchor. |
| children | JSX.Element \| undefined | — | — |
| classes | Classes \| undefined | — | Family slot class defaults for this DropdownMenu instance. |
| styles | Styles \| undefined | — | Family slot style defaults for this DropdownMenu instance. |

### DropdownMenu.Trigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| disabled | boolean \| undefined | — | Whether this trigger is disabled. |
| as | T \| undefined | 'button' | Element or component to render as. |
| children | JSX.Element \| undefined | — | Trigger label and visual content. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |

### DropdownMenu.Content

Props for the DropdownMenu component.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| align | 'start' \| 'center' \| 'end' \| undefined | 'start' | Alignment along the cross axis. |
| checkedIcon | IconT.Name \| undefined | 'icon-check' | Icon used for checked checkbox items. |
| contentBottom | OverlayMenuContentSlot \| undefined | — | Content rendered after the resolved item groups. |
| contentTop | OverlayMenuContentSlot \| undefined | — | Content rendered before the resolved item groups. |
| defaultOpen | boolean \| undefined | false | Initial open state when the component is uncontrolled. |
| disabled | boolean \| undefined | false | Whether trigger interactions should be ignored. |
| gutter | number \| undefined | 0 | Gap between the anchor and the content. |
| id | string \| undefined | — | Unique base id used to derive trigger and content ids. |
| itemProps | ((props: ItemRenderProps) => ElementProps<HTMLDivElement> \| undefined) \| undefined | — | Additional attributes for an interactive menu item. |
| itemRender | ((props: ItemRenderProps) => JSX.Element) \| undefined | — | Renderer for each menu item. |
| items | (Item)[] \| undefined | — | Items rendered in the menu body. |
| onOpenChange | ((open: boolean) => void) \| undefined | — | Called whenever the menu requests an open state change. |
| open | boolean \| undefined | — | Controlled open state of the menu. |
| overflowPadding | number \| undefined | 4 | Padding applied to the overflow area when calculating the menu's position. |
| placement | 'top' \| 'right' \| 'bottom' \| 'left' \| undefined | — | Preferred content placement relative to the trigger or anchor point. |
| preventScroll | boolean \| undefined | true | Whether body scroll should be locked while the menu is open. |
| shift | number \| undefined | 0 | Cross-axis or alignment offset relative to the anchor. |
| size | 'sm' \| 'md' \| 'lg' \| undefined | 'md' | Menu item size variant. |
| submenuIcon | IconT.Name \| undefined | 'icon-chevron-right' | Icon used for submenu trigger items. |
| class | SlotClassValue \| undefined | — | Class applied to the component root or trigger element. |
| classes | ContentClasses \| undefined | — | Family slot class defaults for this instance. |
| style | SlotStyleValue \| undefined | — | Style applied to the component root or trigger element. |
| styles | ContentStyles \| undefined | — | Family slot style defaults for this instance. |
