Contextual menu with items, groups, and keyboard navigation
Tags: component menu navigation
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 itemArrowDown/ArrowUp- Navigate itemsEscape- Close menu- Type to search - Jump to matching item
Accessibility
- Built on Bits UI primitives
- Proper ARIA attributes
- Focus management
- Keyboard navigable
Related Components
- Button - Menu triggers
- AlertDialog - Confirmation dialogs
- Tooltip - Hover information