Overlay

DropdownMenu

Click-triggered dropdown menu with icons, keyboard shortcuts, checkbox/radio items, submenus, and custom rendering.

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

SlotDescription
contentMain dropdown content
arrowArrow element. Its fill is the panel color
arrowBorderStroke along the two slanted edges of the arrow, matching the panel ring
groupItem group container
separatorSeparator
labelGroup label
itemMenu item
itemLeadingIconIcon before label
itemLabelLabel text
itemTrailingKbdsKeyboard shortcuts area
itemIndicatorCheckbox/radio indicator
subTriggerSubmenu trigger
subContentSubmenu content

Snippets

SnippetDescription
childrenTrigger snippet - receives { open, props }; spread props onto the trigger element
headerCustom header (receives close)
footerCustom footer (receives close)
itemCustom item
contentReplace entire items rendering

Props

PropTypeDefault
itemsDropdownMenuItem[]-
radioGroupsRadioGroup[]-
size'xs'|'sm'|'md'|'lg'|'xl''md'
arrowboolean | ArrowPropsfalse
transitionbooleantrue
portalbooleantrue
openbooleanfalse
side'top'|'right'|'bottom'|'left''bottom'
align'start'|'center'|'end''start'
refHTMLElement | nullnull
classstring-
uiRecord<Slot, Class>-