DropdownMenu
Click-triggered dropdown menu with icons, keyboard shortcuts, checkbox/radio items, submenus, and custom rendering.
overflow-visible whenever an arrow is present, and wired up the ui.arrow slot.
v2.6.1 gives it the panel ring: it is stroked along both slanted edges through the new ui.arrowBorder slot, and the fill overlaps the panel edge so the ring reads as one
unbroken outline rather than a line cutting across the arrow's base.Basic Usage
<script lang="ts">
import { DropdownMenu, Button } from 'sv5ui';
const items = [
{ label: 'Edit', icon: 'lucide:pencil' },
{ label: 'Duplicate', icon: 'lucide:copy' },
{ label: 'Archive', icon: 'lucide:archive' },
{ label: 'Delete', icon: 'lucide:trash-2', color: 'error' }
];
</script>
<DropdownMenu {items}>
{#snippet children({ props })}
<Button label="Actions" trailingIcon="lucide:chevron-down" {...props} />
{/snippet}
</DropdownMenu>Keyboard Shortcuts
<script lang="ts">
import { DropdownMenu, Button } from 'sv5ui';
const items = [
{ label: 'Undo', icon: 'lucide:undo-2', kbds: ['meta', 'z'] },
{ label: 'Redo', icon: 'lucide:redo-2', kbds: ['meta', 'shift', 'z'] },
{ type: 'separator' },
{ label: 'Cut', icon: 'lucide:scissors', kbds: ['meta', 'x'] },
{ label: 'Copy', icon: 'lucide:copy', kbds: ['meta', 'c'] },
{ label: 'Paste', icon: 'lucide:clipboard', kbds: ['meta', 'v'] }
];
</script>
<DropdownMenu {items}>
{#snippet children({ props })}
<Button label="Edit" trailingIcon="lucide:chevron-down" {...props} />
{/snippet}
</DropdownMenu>Checkbox Items
Grid: On · Rulers: Off
<script lang="ts">
import { DropdownMenu, Button } from 'sv5ui';
let showStatusBar = $state(true);
let showActivityBar = $state(false);
let showPanel = $state(true);
let items = $derived([
{ type: 'label', label: 'Appearance' },
{ type: 'separator' },
{ type: 'checkbox', label: 'Status Bar', checked: showStatusBar, onCheckedChange: (v) => showStatusBar = v },
{ type: 'checkbox', label: 'Activity Bar', checked: showActivityBar, onCheckedChange: (v) => showActivityBar = v },
{ type: 'checkbox', label: 'Panel', checked: showPanel, onCheckedChange: (v) => showPanel = v }
]);
</script>
<DropdownMenu {items}>
{#snippet children({ props })}
<Button label="View" trailingIcon="lucide:chevron-down" {...props} />
{/snippet}
</DropdownMenu>Radio Items
<script lang="ts">
import { DropdownMenu, Button } from 'sv5ui';
let theme = $state('system');
const items = [
{ type: 'label', label: 'Theme' },
{ type: 'separator' },
{ type: 'radio', label: 'Light', value: 'light' },
{ type: 'radio', label: 'Dark', value: 'dark' },
{ type: 'radio', label: 'System', value: 'system' }
];
const radioGroups = [
{
value: theme,
onValueChange: (v) => theme = v,
items: items.filter((i) => i.type === 'radio')
}
];
</script>
<DropdownMenu {items} {radioGroups}>
{#snippet children({ props })}
<Button label="Theme" trailingIcon="lucide:chevron-down" {...props} />
{/snippet}
</DropdownMenu>Submenu
<script lang="ts">
import { DropdownMenu, Button } from 'sv5ui';
const items = [
{ label: 'New File', icon: 'lucide:file-plus' },
{ label: 'New Folder', icon: 'lucide:folder-plus' },
{ type: 'separator' },
{
type: 'sub',
label: 'Share',
icon: 'lucide:share-2',
items: [
{ label: 'Email', icon: 'lucide:mail' },
{ label: 'Message', icon: 'lucide:message-square' },
{ type: 'separator' },
{ label: 'More...', icon: 'lucide:more-horizontal' }
]
},
{ type: 'separator' },
{ label: 'Delete', icon: 'lucide:trash-2', color: 'error' }
];
</script>
<DropdownMenu {items}>
{#snippet children({ props })}
<Button label="File" trailingIcon="lucide:chevron-down" {...props} />
{/snippet}
</DropdownMenu>Arrow
arrow points the panel at its trigger. It tracks the trigger rather than the panel, and align defaults to start, so
on a panel wider than its trigger the arrow sits toward the leading edge rather than the
middle. Use align="center" to centre it under a narrow trigger.
<!-- arrow points the panel at its trigger. Pass true, or width
and height to size it. -->
<DropdownMenu {items} arrow>
{#snippet children({ props })}
<Button label="Actions" variant="outline" {...props} />
{/snippet}
</DropdownMenu>
<DropdownMenu {items} arrow={{ width: 16, height: 8 }}>
{#snippet children({ props })}
<Button label="Bigger arrow" variant="outline" {...props} />
{/snippet}
</DropdownMenu>
<!-- The arrow tracks the trigger, not the panel. align defaults to
'start', so on a panel wider than its trigger the arrow sits
toward the leading edge rather than the middle. align="center"
is what centres it under a narrow trigger. -->
<DropdownMenu {items} arrow align="center">
{#snippet children({ props })}
<Button label="Centered" variant="outline" {...props} />
{/snippet}
</DropdownMenu>Arrow Colors
The two arrow slots have two different jobs. ui.arrow is the fill, so it tracks the panel background, while ui.arrowBorder is the stroke, so it tracks the panel ring. Recolor the ring alone and only arrowBorder follows; recolor the background and the fill has to move with it, or the arrow reads as a separate
shape stuck to the panel rather than part of it.
<!-- Two slots, two jobs:
ui.arrow is the fill, and tracks the panel BACKGROUND
ui.arrowBorder is the stroke, and tracks the panel RING
Recolor the ring on its own and only arrowBorder follows. -->
<DropdownMenu
{items}
arrow
ui={{ content: 'ring-primary', arrowBorder: 'stroke-primary' }}
>
{#snippet children({ props })}
<Button label="Tinted ring" variant="outline" {...props} />
{/snippet}
</DropdownMenu>
<!-- Recolor the panel background and the fill has to follow, or the
arrow reads as a separate shape stuck to the panel. -->
<DropdownMenu
{items}
arrow
ui={{
content: 'bg-primary text-on-primary ring-primary',
item: 'text-on-primary hover:bg-on-primary/15',
arrow: 'fill-primary text-primary',
arrowBorder: 'stroke-primary'
}}
>
{#snippet children({ props })}
<Button label="Solid panel" variant="outline" {...props} />
{/snippet}
</DropdownMenu>Sizes
<DropdownMenu {items} size="xs">
{#snippet children({ props })}<Button label="XS" {...props} />{/snippet}
</DropdownMenu>
<DropdownMenu {items} size="sm">
{#snippet children({ props })}<Button label="SM" {...props} />{/snippet}
</DropdownMenu>
<DropdownMenu {items} size="lg">
{#snippet children({ props })}<Button label="LG" {...props} />{/snippet}
</DropdownMenu>Disabled Items
<script lang="ts">
import { DropdownMenu, Button } from 'sv5ui';
const items = [
{ label: 'Edit', icon: 'lucide:pencil' },
{ label: 'Duplicate', icon: 'lucide:copy', disabled: true },
{ label: 'Archive', icon: 'lucide:archive', disabled: true },
{ label: 'Delete', icon: 'lucide:trash-2', color: 'error' }
];
</script>
<DropdownMenu {items}>
{#snippet children({ props })}
<Button label="Options" trailingIcon="lucide:chevron-down" {...props} />
{/snippet}
</DropdownMenu>UI Slots
| Slot | Description |
|---|---|
content | Main dropdown content |
arrow | Arrow element. Its fill is the panel color |
arrowBorder | Stroke along the two slanted edges of the arrow, matching the panel ring |
group | Item group container |
separator | Separator |
label | Group label |
item | Menu item |
itemLeadingIcon | Icon before label |
itemLabel | Label text |
itemTrailingKbds | Keyboard shortcuts area |
itemIndicator | Checkbox/radio indicator |
subTrigger | Submenu trigger |
subContent | Submenu content |
Snippets
| Snippet | Description |
|---|---|
children | Trigger snippet - receives { open, props }; spread props onto the trigger element |
header | Custom header (receives close) |
footer | Custom footer (receives close) |
item | Custom item |
content | Replace entire items rendering |
Props
| Prop | Type | Default |
|---|---|---|
items | DropdownMenuItem[] | - |
radioGroups | RadioGroup[] | - |
size | 'xs'|'sm'|'md'|'lg'|'xl' | 'md' |
arrow | boolean | ArrowProps | false |
transition | boolean | true |
portal | boolean | true |
open | boolean | false |
side | 'top'|'right'|'bottom'|'left' | 'bottom' |
align | 'start'|'center'|'end' | 'start' |
ref | HTMLElement | null | null |
class | string | - |
ui | Record<Slot, Class> | - |