Skip to main content

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 for choosing a form value.

Basic usage#

import { Button, DropdownMenu } from 'moraine'

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

Playground#

Props
Slots

Anatomy#

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.

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.

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
↵ / Space / ↓ Opens the menu from an eligible trigger interaction.
↓ / ↑ Moves the active item through available menu entries.
→ Opens an available submenu from its parent item.
← Returns from a submenu to its parent menu.
↵ / Space Activates the current menu item.
Esc 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.

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#

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>
  )
}
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#

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
data-closedSlot: dropdown-menu-trigger, dropdown-menu-contentDescription: Present when disclosure or transition content is closed.
data-disabledSlot: dropdown-menu-trigger, dropdown-menu-itemDescription: Present when the component, slot, or item is disabled.
data-expandedSlot: dropdown-menu-trigger, dropdown-menu-content, dropdown-menu-itemDescription: Present when the panel, accordion, or menu is expanded.
data-alignSlot: dropdown-menu-contentDescription: Stores the resolved alignment of positioned content.
data-sideSlot: dropdown-menu-contentDescription: Stores the resolved floating or drawer content side.
data-destructiveSlot: dropdown-menu-itemDescription: Present when the action has destructive semantics.
data-highlightedSlot: dropdown-menu-itemDescription: Present when the item is highlighted by pointer or keyboard navigation.
data-selectedSlot: dropdown-menu-itemDescription: Present when the item or tab is selected.

Props#

DropdownMenu#

Prop

Trigger#

Renders a <button> element by default.

Prop

Content#

Props for the DropdownMenu component. Renders a <div> element by default.

Prop