Playground
An actions menu: a trigger opens a list of things the user can do, with submenus where a choice needs one. Choosing an item emits sb-select with { name, value }, so a page posts it as a command and shows whatever the server renders next. A menu holds no value, so there is nothing pending and nothing to revert.
An actions menu keeps nothing. No checkmark, no highlighted row, and the trigger keeps its label: it hands an intent over and forgets it, so everything visible afterwards is the page reacting (the demos here write the value into a signal). A menu that answers a question instead of doing something is the exception, and shows what is chosen β give that one a current choice. For a value in a form, reach for sb-select.
The menu is a native popover in the top layer, positioned with CSS anchor positioning where the browser has it (and by hand, flipping and shifting, where it doesn't). No ancestor can clip it.
Examples
Actions
items is a JSON array. An item is a string, {value, label?, description?, icon?, disabled?, danger?}, or {"divider": true} β the string "-" is a divider too.
<div data-signals:_actionPick="''" style="display: grid; gap: 12px; justify-items: start">
<sb-dropdown name="ship" label="Ship actions"
items='["Refuel", {"value":"scan","label":"Long-range scan","icon":"π‘","description":"Takes a while"}, "-", {"value":"dock","label":"Dock","disabled":true}, {"value":"scuttle","label":"Scuttle","icon":"π₯","danger":true}]'
data-on:sb-select="$_actionPick = evt.detail.name + ' β ' + evt.detail.value"></sb-dropdown>
<code data-text="$_actionPick || 'Pick somethingβ¦'"></code>
</div>
Submenus
An item with children opens a submenu instead of reporting a value: the children win, so a parent never emits sb-select even when it carries a value. Nest up to five levels; anything deeper is dropped, so a runaway tree cannot build a menu nobody can reach.
<div data-signals:_docPick="''" style="display: grid; gap: 12px; justify-items: start">
<sb-dropdown name="doc" label="Document"
items='[{"value":"rename","label":"Rename","icon":"βοΈ"},
{"label":"Export","icon":"π¦","children":[
{"value":"csv","label":"CSV"},
{"value":"json","label":"JSON","description":"Pretty printed"},
{"label":"Archive","children":[{"value":"zip","label":"ZIP"},{"value":"tar","label":"TAR"}]}]},
{"label":"Move to","icon":"π","children":[{"value":"drafts","label":"Drafts"},{"value":"sent","label":"Sent"},{"value":"trash","label":"Trash","danger":true}]},
{"divider":true},
{"value":"delete","label":"Delete","icon":"π₯","danger":true}]'
data-on:sb-select="$_docPick = evt.detail.value"></sb-dropdown>
<code data-text="$_docPick || 'Nothing chosen yet'"></code>
</div>
A submenu opens to the inline end of its parent item and flips to the start when there is no room. Only one submenu per level is open at a time, and choosing a leaf closes every level at once. Submenus are view state: they raise no events and the server never hears about them.
A current choice
Some menus answer a question instead of doing something β Sort by, Density, Theme. Make the root menu one radio group with type="radio", or a single submenu into one with type: "radio" on its parent item.
An item of the group changes a value, so it emits sb-change with { name, value }; every other item stays an intent and emits sb-select. A menu may mix both kinds, and no item ever sends both.
The checked value is value, and the server owns it like every other value here: a changed attribute wins, value="" clears it, a removed one is ignored, and the live value is the value property. The demo plays the server with a signal β the menu reports the choice, the "server" sends the value back, and the check follows it.
<div data-signals="{_sortPick: 'name'}" style="display: grid; gap: 12px; justify-items: start">
<sb-dropdown name="sort" label="Sort by" type="radio" data-preserve-attr="value"
data-attr:value="$_sortPick"
items='[{"value":"name","label":"Name"},{"value":"size","label":"Size"},{"value":"modified","label":"Last modified"}]'
data-on:sb-change="$_sortPick = evt.detail.value"></sb-dropdown>
<code data-text="'Sorted by ' + $_sortPick"></code>
</div>
A group inside a submenu is the same thing one level down, and the rest of the menu goes on being actions:
<div data-signals="{_filePick: 'name', _fileDid: ''}" style="display: grid; gap: 12px; justify-items: start">
<sb-dropdown name="file" label="File" data-preserve-attr="value" data-attr:value="$_filePick"
items='[{"value":"rename","label":"Rename","icon":"βοΈ"},
{"label":"Sort by","icon":"βοΈ","type":"radio","children":[
{"value":"name","label":"Name"},{"value":"size","label":"Size"},{"value":"modified","label":"Last modified"}]},
{"divider":true},
{"value":"delete","label":"Delete","icon":"π₯","danger":true}]'
data-on:sb-change="$_filePick = evt.detail.value; $_fileDid = ''"
data-on:sb-select="$_fileDid = evt.detail.value"></sb-dropdown>
<code data-text="$_fileDid ? 'Command: ' + $_fileDid : 'Sorted by ' + $_filePick"></code>
</div>
The trigger shows the choice. With a value set it reads <label>: <chosen label> β Sort by: Stars β and falls back to the plain label when nothing is chosen. The label stays yours: set it statically or with data-attr and the trigger composes from whatever it says. An actions menu is untouched by this and keeps its label as it always did.
A group across submenus
A group may reach into its submenus. The value is then the item values from the group root down to the leaf, joined with dots:
<div data-signals="{_nestPick: 'stars'}" style="display: grid; gap: 12px; justify-items: start">
<sb-dropdown name="order" label="Sort by" type="radio" data-preserve-attr="value" data-attr:value="$_nestPick"
items='[{"value":"name","label":"Name"},
{"value":"stars","label":"Stars"},
{"value":"date","label":"Date","children":[
{"value":"newest","label":"Newest first"},
{"value":"oldest","label":"Oldest first"}]}]'
data-on:sb-change="$_nestPick = evt.detail.value"></sb-dropdown>
<code data-text="'value=' + JSON.stringify($_nestPick)"></code>
</div>
Choosing Newest first reports date.newest, and the trigger reads Sort by: Newest first β the leaf's own label, because the menu already shows which branch it sits in. A parent on the way is never selectable: it opens its submenu like any other parent, reports nothing of its own, and carries a faint mark so you can see where the choice lives. The server sets and clears the whole path as one value, with the rules from above: value="date.oldest" moves the check across levels, value="" clears it. Keep dots out of the item values of a group β they separate the segments.
One group per dropdown. A second type: "radio" is ignored and reported to the console instead of guessed at; two questions want two dropdowns. Checkbox groups, with several items checked at once, can follow if anyone needs them.
With confirm, the item the user chose stays marked pending until the server's value says the same, and revert() puts it back when the command is rejected β the same contract every value component follows:
<sb-dropdown name="sort" label="Sort by" type="radio" confirm value="name"
items='[{"value":"name","label":"Name"},{"value":"size","label":"Size"}]'
data-on:sb-change="@post('/cmd/sort', {payload: {tabid: $tabid, ...evt.detail}})"
data-on:datastar-fetch="evt.detail.el === el && evt.detail.type === 'error' && el.revert()"></sb-dropdown>
sb-dropdown::part(pending) { outline: 1px dashed var(--sb-border-strong); }
sb-dropdown:state(pending) { opacity: 0.85; }
Placement
placement is the preferred side and alignment: bottom-start, bottom, bottom-end, top-start, top or top-end. The menu flips to the other side and shifts back into the viewport when there is no room.
<div style="display: flex; gap: 12px; flex-wrap: wrap">
<sb-dropdown label="Bottom end" placement="bottom-end" items='["Rename", "Duplicate", "-", "Delete"]'></sb-dropdown>
<sb-dropdown label="Top start" placement="top-start" items='["Rename", "Duplicate", "-", "Delete"]'></sb-dropdown>
</div>
Your own trigger
The trigger slot fills the trigger with your own content β text, an icon, an sb-β¦ component. The button itself stays ours, so aria-haspopup, aria-expanded and the keyboard live in the shadow root and no morph can strip them. Keep the slot free of interactive elements (a button inside a button), and name the menu with label.
<sb-dropdown label="More" placement="bottom-end" items='[{"value":"copy","label":"Copy link","icon":"π"},{"value":"share","label":"Share","icon":"π€"},{"divider":true},{"value":"remove","label":"Remove","danger":true}]'>
<svg slot="trigger" width="16" height="4" viewBox="0 0 16 4" fill="currentColor" aria-hidden="true"><rect x="0" y="0" width="4" height="4"/><rect x="6" y="0" width="4" height="4"/><rect x="12" y="0" width="4" height="4"/></svg>
</sb-dropdown>
Items as markup
Instead of items, write the menu as light DOM. The items are read as data (label, value, disabled, data-icon, data-description, data-danger, and <hr> for a divider) and rendered inside the menu, so the component never writes roles or tabindex into your markup, where the next morph would strip them. items wins whenever it is not empty.
<div data-signals:_crewPick="''" style="display: grid; gap: 12px; justify-items: start">
<sb-dropdown label="Crew" data-on:sb-select="$_crewPick = evt.detail.value">
<button slot="item" value="ada" data-icon="π©βπ" data-description="Flight engineer">Ada</button>
<button slot="item" value="yuri" data-icon="π§βπ">Yuri</button>
<hr slot="item">
<button slot="item" value="eject" data-danger disabled>Eject</button>
</sb-dropdown>
<code data-text="$_crewPick || 'Nobody yet'"></code>
</div>
A slotted item opens a submenu with data-children='[β¦]', the same JSON as items. Markup stops being the clearer form once a menu nests, so a deep tree belongs in items:
<button slot="item" data-children='[{"value":"csv","label":"CSV"},{"value":"json","label":"JSON"}]'>Export</button>
Server data
The menu is server data: a new items array replaces the whole tree whenever the server likes, and an open menu stays open β the open state is local and lives in a signal, so nothing about it is reset by a morph.
<div data-signals="{_fleetAlt: false}" style="display: grid; gap: 12px; justify-items: start">
<sb-dropdown label="Fleet" data-preserve-attr="items"
data-attr:items="JSON.stringify($_fleetAlt ? ['Recall', 'Refit', '-', 'Decommission'] : ['Launch', 'Hold', '-', 'Scrub'])"></sb-dropdown>
<button type="button" data-on:click="$_fleetAlt = !$_fleetAlt">Swap the items (try it while the menu is open)</button>
</div>
On a real page the same thing happens without the client signal: the server renders <sb-dropdown items='β¦'> again and the morph brings the new array. data-preserve-attr is only needed when a signal drives the attribute, as in the demo above.
With commands
Give it a name and post the detail as it is:
<sb-dropdown name="ship" label="Ship actions" items='[{"value":"refuel","label":"Refuel"}]'
data-on:sb-select="@post('/cmd/ship', {payload: {tabid: $tabid, ...evt.detail}})"></sb-dropdown>
A plain item is an intent, not a value: nothing is pending and there is nothing to revert, and the component shows no result of its own (a radio group is the exception, and has both). The server decides, and the page re-renders β see Commands and components. When an item starts something slow, let the server render the pending state (a disabled item, a spinner in the page), never the menu.
Open and closed
Opening and closing is local state. Clicks, the keyboard, an outside click and Escape only ever change a $$ signal, so a server morph can never re-open a menu the user just closed.
The server still gets a say through the open attribute, with the usual rule:
- The first
opensets the initial state β<sb-dropdown open items='β¦'>is open on the first paint, without the opening animation. - A changed attribute wins over the local state:
open="false"closes the menu,openre-opens it. - Re-sent identical markup changes nothing, because the morph never touches an attribute it already agrees with. That is what makes the attribute safe to render on every frame.
- A removed attribute is ignored, like every other reflected attribute (morphs strip those). To close from the server, send
open="false".
From the client, use the property and the methods instead β they never touch the attribute:
el.open // true or false, live
el.show() // open, and move the focus to the first item
el.hide() // close, and leave the focus where it is
sb-open fires when it opens, sb-close with { reason } (item, escape, outside, scroll, tab, trigger, server or api) when it closes.
<div data-signals="{_openState: 'closed'}" style="display: grid; gap: 12px; justify-items: start">
<sb-dropdown label="Watch me" items='["Rename", "Duplicate", "-", "Delete"]'
data-on:sb-open="$_openState = 'open'"
data-on:sb-close="$_openState = 'closed (' + evt.detail.reason + ')'"></sb-dropdown>
<code data-text="$_openState"></code>
</div>
Styling
Colours come from --sb-control-bg, --sb-control-border, --sb-surface-raised, --sb-surface-hover, --sb-brand, --sb-text-muted and --sb-danger; --sb-notch: 0 rounds the pixel corners of trigger and menu. Parts: trigger, menu (every level) and item, which also carries checked, onpath (a parent the choice sits under) and pending in a radio group.
sb-dropdown::part(trigger) { font-weight: 700; }
sb-dropdown::part(menu) { --sb-surface-raised: #1B1030; }
Accessibility
It follows the WAI-ARIA menu button pattern:
- Structure: the trigger is a
buttonwitharia-haspopup="menu"andaria-expanded; the menu is amenunamed bylabel, its rows aremenuitems, dividers areseparators and disabled items arearia-disabled. - A radio group: its rows are
menuitemradiowitharia-checked, and the group is named by the menu it lives in β the trigger label for a root group, the parent item for a submenu group. Every row of the group reserves the mark column, so the menu does not jump when the choice moves. A parent that only leads to the choice stays amenuitemwitharia-haspopup: its faint mark is decoration (aria-hidden), never a checked state. The trigger names what the menu is and what is chosen ("Sort by: Newest first"), so the visible text and the accessible name stay the same string. - Keys: Enter, Space and Down open the menu at the first item, Up at the last one. Up and Down move, Home and End jump, typing a few letters jumps to a matching item (the buffer never leaks from one level into another). Enter and Space choose, Escape and Tab close and hand the focus back to the trigger.
- Submenus: Right (Left in a right-to-left page), Enter or Space on a parent opens its submenu and moves the focus to its first item; Left or Escape closes it again and puts the focus back on the parent item, so the keyboard walks in and out without ever leaving the menu. Escape at the root closes the whole thing. A parent item is a
menuitemwitharia-haspopup="menu"andaria-expanded, and its submenu is amenunamed after it. - Pointer: hovering a parent opens its submenu after a moment and leaving closes it a little later, so a diagonal path from the item into the submenu keeps it. Tapping a parent opens its submenu and tapping it again closes it, which is the only way back on a touch screen.
- Focus: real DOM focus moves onto the row inside the shadow root, so screen readers announce it and nothing in your markup is touched. When the server replaces the items while the menu is open, a row that is gone hands the focus to its neighbour, and the focus is only picked back up when it fell on the floor β see Lists that hold the keyboard. A submenu whose parent is no longer a parent closes itself.
- Pointer: an outside click closes the menu, disabled items ignore clicks.
- Motion: the opening animation is skipped under
prefers-reduced-motion, and for a menu that is already open on the first render.
Installation
Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag. The autoloader imports each component the first time its tag appears, including tags added later by a Datastar morph.
<!-- Once per page: Datastar with Rocket, and the Starbase autoloader.
It loads every <sb-β¦> component the first time its tag appears. -->
<script type="importmap">
{ "imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<!-- Optional, no flash of undefined elements: class="sb-cloak" on <html>, and -->
<style>.sb-cloak :not(:defined) { visibility: hidden }</style>
<sb-dropdown data-signals:_cardPick="''" data-preserve-attr="label"
data-attr:label="$_cardPick ? 'Sent: ' + $_cardPick : 'Ship actions'"
data-on:sb-select="$_cardPick = evt.detail.value"
items='[{"value":"refuel","label":"Refuel","icon":"β½"},{"label":"Set course","icon":"π§","children":[{"value":"mars","label":"Mars"},{"value":"europa","label":"Europa"}]},{"divider":true},{"value":"scuttle","label":"Scuttle","icon":"π₯","danger":true}]'></sb-dropdown>
<!-- In production, pin today's catalog instead of the latest: the browser then
refuses any file that changed. Add "integrity" to the import map above: the
hashes from https://starbase.zweiundeins.gmbh/c/@722e189276ac/importmap.json and Datastar's, below. -->
<!--
<script type="importmap">
{ "imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" },
"integrity": { "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js": "sha384-vUxZojLrF1Ar3de5h7VINqhJXBgjyZS4U49pHvGa87kum6j5Xn6JRziuARNw5ELG", "β¦": "β¦from importmap.json" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@722e189276ac/autoloader.js" integrity="sha384-scFa7/4jztDOx1YZT9/1g1ULYm9LsDlQn/8hMTyyD4IkPnZV2pfhHytoNTWTJG6b"></script>
-->
<!-- Or load just this component, pinned to this version. The minified module
is what the autoloader uses; the readable source is the same URL without .min. -->
<!-- <script type="module" src="https://starbase.zweiundeins.gmbh/c/dropdown@80f6e072b81c/dropdown.min.js" integrity="sha384-w5XT1hgqZ+LOGh1l0u8CJvQFFTeI5MZK9/wJZMr03cJyMB9HO44zqCrc+m9jxL8a"></script> -->Size
Each file compressed on its own, the way it is served (gzip -9, brotli -11). The autoloader loads the minified files (esbuild), so that last column is what a page downloads; the readable source is always there too. Datastar and Rocket are shared by every component and not counted.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
dropdown.js | 39.9 kB | 13.3 kB | 11.7 kB | 7.9 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
items | json | [] | The menu, as JSON: ["Rename", "-", {"value":"delete","label":"Delete","danger":true}]. An item is {value, label?, description?, icon?, disabled?, danger?}, {"divider":true} ("-" works too), or a submenu {label, children:[...]} nested up to 5 levels deep. Server data: a new array replaces the whole tree, open or not. |
label | string | "Actions" | Text of the default trigger, and the accessible name of trigger and menu. |
placement | "bottom-start" | "bottom" | "bottom-end" | "top-start" | "top" | "top-end" | "bottom-start" | Preferred side and alignment of the menu; it flips and shifts when there is no room. Submenus always open to the inline end and flip to the start. |
type | "actions" | "radio" | "actions" | radio makes the root menu one radio group, for a menu that shows a current choice; a group inside a submenu is type:"radio" on that item instead. The group reaches into its submenus, and a dropdown holds one group. |
value | string | "" | Radio group: the checked value, owned by the server β for a choice in a submenu the item values from the group root down, joined with dots ("date.newest"). A new value from the server wins (value="" clears it); the live value is the value property. |
confirm | boolean | false | Radio group: :state(pending) on the host and on the chosen item while the local value differs from the server's value attribute (see revert()). |
open | boolean | false | Open on first render. A changed attribute from the server opens or closes the menu (open="false" closes); re-sent identical markup leaves the local state alone. Never reflected: use the open property, show() and hide() from the client. |
disabled | boolean | false | Disable the trigger (and close the menu). |
name | string | "" | Name reported in sb-select (e.g. the field of a command). |
Slots
| Name | Description |
|---|---|
trigger | Content of the trigger button (text, an icon). The component provides the button itself, with all the ARIA on it. |
item | Menu items as markup instead of items: <button slot="item" value="x" disabled data-icon="π°" data-description="β¦" data-danger>Label</button>, or <hr slot="item"> for a divider. A submenu is data-children='[β¦]' (the same JSON as items). They are read as data; items wins when it is not empty. |
Events
| Name | Description |
|---|---|
sb-select | A plain item was chosen; the whole menu closes. detail: { name, value }: ready for a command. A submenu parent never reports a value, and an item of a radio group reports sb-change instead. |
change | Radio group: the value changed. |
sb-change | Radio group: an item of the group was chosen. detail: { name, value }, the value being the dotted path for a choice inside a submenu: ready for a command. |
sb-open | The menu opened (the root; submenus are view state and stay quiet). |
sb-close | The menu closed. detail: { reason }: item, escape, outside, scroll, tab, trigger, server or api. |