GitHub repository

Menu

Dropdown menu — trigger + floating list of actions.

A trigger button that opens a floating panel of selectable items, with click-outside / Esc dismissal and full roving-focus keyboard navigation. Generalises the hand-rolled theme/language/overflow menus into one token-driven primitive (zero radius, --ss-shadow-pop). When any item carries a `selected` flag the rows become `menuitemradio` with a check marker; otherwise they are plain `menuitem` action rows.

Demo

live · storybook Open in Storybook

Demo loads from https://dssoca-storybook.vercel.app/. Run pnpm storybook for it to render locally.

Usage

svelte
<script>
  import { Menu } from 'dssoca';

  let open = $state(false);
  const items = [
    { id: 'edit', label: 'Edit', icon: 'note' },
    { id: 'dup', label: 'Duplicate', icon: 'grid' },
    { id: 'delete', label: 'Delete', icon: 'logs', onSelect: (id) => remove(id) },
  ];

  // Custom leading visuals: a theme picker with color swatches,
  // a language picker with emoji flags.
  const themes = [
    { id: 'dark', label: 'Dark', swatch: '#0a0a0a', selected: true },
    { id: 'lime', label: 'Lime', swatch: '#bef264', selected: false },
  ];
  const langs = [
    { id: 'en', label: 'English', emoji: '🇬🇧', selected: true },
    { id: 'pt', label: 'Português', emoji: '🇧🇷', selected: false },
  ];
</script>

<Menu {items} bind:open align="end" label="Actions">
  Actions
</Menu>

<Menu items={themes} label="Theme">Theme</Menu>
<Menu items={langs} label="Language">Language</Menu>

Trigger exposes aria-haspopup="menu" + aria-expanded/aria-controls; the panel is role="menu" with role="menuitem" (or menuitemradio + aria-checked when selectable). Keyboard: ArrowDown/ArrowUp open from the trigger; ArrowUp/Down/Home/End rove focus (skipping disabled rows), Enter/Space activate, Esc closes and returns focus to the trigger. Closes on item select, outside pointerdown, and Esc. Per-item leading visuals (`leading` snippet / `swatch` color chip / `emoji`) are decorative (aria-hidden) — the row's accessible name is always its `label`. WCAG 2.2 AA.

HTML

interactive with vanilla.js The exact markup the component renders, generated at build time. Use it with dssoca/vanilla.css+ dssoca/vanilla.js — see Plain HTML & CSS.

html
<div class="ss-menu" data-align="start" data-side="bottom">
  <button type="button" class="trigger" aria-haspopup="menu" aria-expanded="false" aria-controls="menu-s1-panel">Actions</button>
  <div id="menu-s1-panel" class="panel" role="menu" aria-label="Actions" aria-hidden="true" tabindex="-1">
    <button type="button" class="item" data-mi="0" role="menuitem" tabindex="-1">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" class="ss-icon" style="width:var(--ss-icon);height:var(--ss-icon)" aria-hidden="true" focusable="false">
        <rect x="4" y="3" width="16" height="18"/>
        <path d="M8 8h8M8 12h8M8 16h6"/>
      </svg>
      <span class="label">Edit</span>
      <span class="marker" aria-hidden="true"></span>
    </button>
    <button type="button" class="item" data-mi="1" role="menuitem" tabindex="-1">
      <span class="label">Delete</span>
      <span class="marker" aria-hidden="true"></span>
    </button>
  </div>
</div>

vanilla.js opens/closes the panel, roves focus (arrows, Home/End, Escape, outside click) and emits ss:select with the item index / data-value.

Props

PropTypeDefaultDescription
itemsMenuItem[][]Rows in the panel. Each: { id, label, icon?, leading?, swatch?, emoji?, disabled?, selected?, onSelect? }. A defined `selected` on any item switches the menu to a selection (radio) group. Leading visual per item: `leading` (a Svelte 5 Snippet for arbitrary markup), `swatch` (any CSS color, rendered as a token-sized square color chip — zero radius), or `emoji` (a text glyph, e.g. a flag); precedence when several are set is leading → swatch → emoji → icon. All leading visuals share the `--ss-icon` footprint so mixed menus align, and are aria-hidden (the label carries the semantics).
openbooleanfalseBindable open state. Omit `bind:` for an uncontrolled menu the trigger toggles itself.
onSelect(id: string) => void—Fired when a row is activated, with its `id` (after the item's own `onSelect`).
align'start' | 'end''start'Horizontal edge the panel aligns to relative to the trigger.
side'bottom' | 'top''bottom'Vertical side the panel opens toward.
labelstring—Accessible name for the panel (`role="menu"`).
childrenSnippet—Trigger content rendered inside the toggle button.
size'sm' | 'md' | 'lg'—Per-instance size override; inherits the ancestor `data-size-variant` when unset.
↑↓ navigate · ↵ open · esc close

Keyboard shortcuts

No shortcuts registered

Shortcuts added through the dssoca registry will show up here.

Single-key shortcuts