Skip to main content

DropdownMenu

DropdownMenu

Contextual menu with items, groups, and keyboard navigation

Tags: component menu navigation

View Demo

DropdownMenu provides a contextual menu triggered by a button or other element, with support for items, groups, separators, and keyboard navigation.

Import​

<script>
  import { DropdownMenu } from '@classic-homes/theme-svelte';
</script>

Basic Usage​

<script>
  const items = [
    { label: 'Profile', onSelect: () => goto('/profile') },
    { label: 'Settings', onSelect: () => goto('/settings') },
    { type: 'separator' },
    { label: 'Log out', onSelect: logout, destructive: true },
  ];
</script>

<DropdownMenu {items} triggerVariant="outline">
  {#snippet trigger()}Menu{/snippet}
</DropdownMenu>

Props​

Prop Type Default Description
items (DropdownMenuItem | DropdownMenuGroup)[] required Menu items
trigger Snippet required Trigger element
side 'top' | 'right' | 'bottom' | 'left' 'bottom' Menu position
align 'start' | 'center' | 'end' 'start' Menu alignment
open boolean false Controlled open state (bindable)
onOpenChange (open: boolean) => void - Open change callback
class string - Additional classes
triggerClass string - Classes for the trigger <button> (style it here rather than nesting a button in trigger)
triggerVariant ButtonVariants['variant'] - Style the trigger as a Button variant (e.g. ghost for header navigation). Unset leaves the trigger unstyled, as before; triggerClass still applies on top.
triggerSize ButtonVariants['size'] 'sm' Button size for the trigger, when triggerVariant is set. Default: sm.
triggerLabel string - Accessible name for the trigger <button> (for icon-only triggers)
showChevron boolean false Show a disclosure chevron after the trigger content, turned while the menu is open, so a text trigger (e.g. header navigation) reads as a menu rather than a link.
triggerDisabled boolean false Disable the trigger <button>: native disabled, and the menu cannot open by pointer or keyboard. Use this instead of nesting a disabled <Button> in trigger.
triggerStopPropagation boolean false Keep the trigger's pointer, mouse, click and opening-key (Enter, Space, ArrowDown) events from bubbling past the trigger, so a menu in a clickable row or card opens without activating the row.
onTriggerClick (event: MouseEvent) => void - Called on a click of the trigger, before the event bubbles to its ancestors.

Types​

interface DropdownMenuItem {
  type?: 'item' | 'separator' | 'label';
  label?: string;
  value?: string;
  disabled?: boolean;
  onSelect?: () => void;
  destructive?: boolean;
  shortcut?: string;
}

interface DropdownMenuGroup {
  label?: string;
  items: DropdownMenuItem[];
}

Item Types​

Regular Items​

<script>
  const items = [
    { label: 'Edit', onSelect: handleEdit },
    { label: 'Duplicate', onSelect: handleDuplicate },
    { label: 'Delete', onSelect: handleDelete, destructive: true },
  ];
</script>

With Separators​

<script>
  const items = [
    { label: 'Cut', shortcut: '⌘X' },
    { label: 'Copy', shortcut: '⌘C' },
    { label: 'Paste', shortcut: '⌘V' },
    { type: 'separator' },
    { label: 'Select All', shortcut: '⌘A' },
  ];
</script>

Labels​

<script>
  const items = [{ type: 'label', label: 'Actions' }, { label: 'Edit' }, { label: 'Delete' }];
</script>

Disabled Items​

<script>
  const items = [
    { label: 'Save', disabled: !hasChanges },
    { label: 'Publish', disabled: !canPublish },
  ];
</script>

Grouped Items​

Organize items into groups:

<script>
  const items = [
    {
      label: 'File',
      items: [
        { label: 'New', shortcut: '⌘N' },
        { label: 'Open', shortcut: '⌘O' },
        { label: 'Save', shortcut: '⌘S' },
      ],
    },
    {
      label: 'Edit',
      items: [
        { label: 'Undo', shortcut: '⌘Z' },
        { label: 'Redo', shortcut: '⌘⇧Z' },
      ],
    },
  ];
</script>

<DropdownMenu {items} triggerVariant="default">
  {#snippet trigger()}Actions{/snippet}
</DropdownMenu>

Positioning​

Side​

<!-- Below trigger (default) -->
<DropdownMenu {items} side="bottom">...</DropdownMenu>

<!-- Above trigger -->
<DropdownMenu {items} side="top">...</DropdownMenu>

<!-- To the right -->
<DropdownMenu {items} side="right">...</DropdownMenu>

<!-- To the left -->
<DropdownMenu {items} side="left">...</DropdownMenu>

Alignment​

<!-- Align to start (default) -->
<DropdownMenu {items} align="start">...</DropdownMenu>

<!-- Center aligned -->
<DropdownMenu {items} align="center">...</DropdownMenu>

<!-- Align to end -->
<DropdownMenu {items} align="end">...</DropdownMenu>

Controlled State​

<script>
  let menuOpen = $state(false);
</script>

<DropdownMenu
  {items}
  bind:open={menuOpen}
  onOpenChange={(open) => console.log('Menu:', open)}
  triggerVariant="default"
>
  {#snippet trigger()}Menu ({menuOpen ? 'Open' : 'Closed'}){/snippet}
</DropdownMenu>

Keyboard Shortcuts​

Display shortcuts in menu items:

<script>
  const items = [
    { label: 'Save', shortcut: '⌘S', onSelect: save },
    { label: 'Save As...', shortcut: '⌘⇧S', onSelect: saveAs },
    { type: 'separator' },
    { label: 'Export', shortcut: '⌘E', onSelect: exportFile },
  ];
</script>

Examples​

User Menu​

<script>
  const items = [
    { type: 'label', label: user.email },
    { type: 'separator' },
    { label: 'Profile', onSelect: () => goto('/profile') },
    { label: 'Settings', onSelect: () => goto('/settings') },
    { label: 'Billing', onSelect: () => goto('/billing') },
    { type: 'separator' },
    { label: 'Help', onSelect: openHelp },
    { type: 'separator' },
    { label: 'Log out', onSelect: logout, destructive: true },
  ];
</script>

<DropdownMenu {items} align="end">
  {#snippet trigger()}
    <Avatar>{user.initials}</Avatar>
  {/snippet}
</DropdownMenu>

Context Actions​

<script>
  const items = [
    { label: 'Open', onSelect: openItem },
    { label: 'Open in new tab', onSelect: openInNewTab },
    { type: 'separator' },
    { label: 'Rename', shortcut: 'F2', onSelect: rename },
    { label: 'Duplicate', shortcut: '⌘D', onSelect: duplicate },
    { label: 'Move to...', onSelect: moveTo },
    { type: 'separator' },
    { label: 'Delete', destructive: true, onSelect: deleteItem },
  ];
</script>

<DropdownMenu {items} triggerVariant="ghost" triggerSize="icon" triggerLabel="More actions">
  {#snippet trigger()}
    <MoreHorizontalIcon />
  {/snippet}
</DropdownMenu>

Table Row Actions​

Style the trigger with triggerVariant / triggerSize / triggerLabel rather than nesting a <Button> in trigger (that renders a button inside the trigger button). In a clickable row, triggerStopPropagation opens the menu without also activating the row:

{#each rows as row}
  <tr onclick={() => openRow(row)}>
    <td>{row.name}</td>
    <td>
      <DropdownMenu
        items={getRowActions(row)}
        align="end"
        triggerVariant="ghost"
        triggerSize="icon"
        triggerLabel={`Actions for ${row.name}`}
        triggerStopPropagation
      >
        {#snippet trigger()}
          <MoreHorizontalIcon />
        {/snippet}
      </DropdownMenu>
    </td>
  </tr>
{/each}

Disabled Trigger​

<DropdownMenu {items} triggerVariant="outline" triggerDisabled={!canEdit}>
  {#snippet trigger()}Insert snippet{/snippet}
</DropdownMenu>

Keyboard Navigation​

  • Enter / Space - Open menu or select item
  • ArrowDown / ArrowUp - Navigate items
  • Escape - Close menu
  • Type to search - Jump to matching item

Accessibility​

  • Built on Bits UI primitives
  • Proper ARIA attributes
  • Focus management
  • Keyboard navigable
On this page