A menu of actions triggered by a button. Supports submenus, checkbox items, radio groups, keyboard navigation, and typeahead.
Anatomy
<DropdownMenu.Provider bind:open>
<DropdownMenu.Trigger>Actions</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item>Cut</DropdownMenu.Item>
<DropdownMenu.Item>Copy</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem bind:checked>Show Toolbar</DropdownMenu.CheckboxItem>
<DropdownMenu.RadioGroup bind:value>
<DropdownMenu.RadioItem value="light">Light</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="dark">Dark</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Sub bind:open={subOpen}>
<DropdownMenu.SubTrigger>More</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Item>Submenu Item</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Provider>
Parts
| Part |
Element |
Description |
Provider |
none |
Root context. Manages open state. |
Trigger |
<button> |
Opens the menu on click or keyboard. |
Content |
<div> |
Menu container. Focus scope, dismissal, floating integrated. |
Arrow |
<div> |
Floating arrow pointing toward the trigger. |
Item |
<div> |
Action item. Selectable via click or keyboard. |
Group |
<div> |
Groups related items with an optional heading. |
GroupHeading |
<div> |
Label for a group, linked via aria-labelledby. |
Separator |
<div> |
Visual separator between items or groups. |
CheckboxItem |
<div> |
Toggleable item with checked/unchecked state. |
CheckboxGroup |
none |
Optional parent. Binds value: string[] and flips checked state of child CheckboxItems by their value. |
RadioGroup |
<div> |
Group of mutually exclusive radio items. |
RadioItem |
<div> |
Radio option within a RadioGroup. |
Sub |
none |
Submenu context. Manages submenu open state. |
SubTrigger |
<div> |
Opens a submenu on hover or keyboard. |
SubContent |
<div> |
Submenu container. Positioned relative to SubTrigger. |
ARIA
| Part |
Attribute |
Value |
| Trigger |
aria-haspopup |
menu |
| Trigger |
aria-expanded |
true | false |
| Trigger |
aria-controls |
ID of Content |
| Content |
role |
menu |
| Content |
aria-orientation |
vertical |
| Item |
role |
menuitem |
| CheckboxItem |
role |
menuitemcheckbox |
| CheckboxItem |
aria-checked |
true | false |
| RadioGroup |
role |
group |
| RadioItem |
role |
menuitemradio |
| RadioItem |
aria-checked |
true | false |
| Group |
role |
group |
| Group |
aria-labelledby |
ID of GroupHeading |
| Separator |
role |
separator |
| Separator |
aria-orientation |
horizontal |
| SubTrigger |
role |
menuitem |
| SubTrigger |
aria-haspopup |
menu |
| SubTrigger |
aria-expanded |
true | false |
| SubTrigger |
aria-controls |
ID of SubContent |
| SubContent |
role |
menu |
| SubContent |
aria-orientation |
vertical |
Data Attributes
| Part |
Attribute |
Values |
| Trigger |
data-dropdown-menu-trigger |
Always present |
| Trigger |
data-state |
open | closed |
| Content |
data-dropdown-menu-content |
Always present |
| Content |
data-state |
open | closed |
| Content |
data-side |
top | right | bottom | left |
| Content |
data-align |
start | center | end |
| Content |
data-starting-style |
Present during open animation |
| Content |
data-ending-style |
Present during close animation |
| Item |
data-dropdown-menu-item |
Always present |
| Item |
data-disabled |
Present when disabled |
| Item |
data-text-value |
Text content for typeahead |
| CheckboxItem |
data-dropdown-menu-checkbox-item |
Always present |
| CheckboxItem |
data-state |
checked | unchecked |
| CheckboxItem |
data-disabled |
Present when disabled |
| RadioItem |
data-dropdown-menu-radio-item |
Always present |
| RadioItem |
data-state |
checked | unchecked |
| RadioItem |
data-disabled |
Present when disabled |
| SubTrigger |
data-dropdown-menu-sub-trigger |
Always present |
| SubTrigger |
data-state |
open | closed |
| SubTrigger |
data-disabled |
Present when disabled |
| SubContent |
data-dropdown-menu-sub-content |
Always present |
| SubContent |
data-state |
open | closed |
| Separator |
data-dropdown-menu-separator |
Always present |
Keyboard
On Trigger
| Key |
Action |
ArrowDown / Enter / Space |
Open menu, focus first item |
On Content
| Key |
Action |
ArrowDown |
Move to next item |
ArrowUp |
Move to previous item |
Home |
Move to first item |
End |
Move to last item |
Enter / Space |
Activate focused item |
Escape |
Close menu |
| Character keys |
Typeahead — jump to matching item |
On SubTrigger
| Key |
Action |
ArrowRight (LTR) |
Open submenu |
Enter / Space |
Open submenu |
On SubContent
| Key |
Action |
ArrowLeft (LTR) / Escape |
Close submenu, focus SubTrigger |
ArrowDown / ArrowUp |
Navigate items within submenu |
Motion
The panel uses the standard overlay state-preset: motion="slide-fade" (default) writes
data-animation-style, and the recipe animates it over data-state (dropdown-menu-enter on open).
The open/close sema firma (emerge) plays on the content during the hold (see Sema events).
A previous opt-in item cascade (animation="cascade-slide", the rows staggering in/out via a
DomCascade) was removed on 2026-06-19 along with the rest of the parallel motion-orchestration
service — see the retirement note in
MOTION_SERVICE_RFC.md. Coordinated per-row motion will
return as a channel of the menu's emerge.open firma, materialised by eidos — not a parallel motor.
Sema events
| Event |
Family |
Verb |
Target |
Intent |
Sequence |
When |
open |
emerge |
open |
content |
— |
pre |
Menu opens (handleOpen central state mutator). |
close |
emerge |
close |
content |
— |
pre |
Menu closes (handleClose). |
commit-select |
commit |
select |
item |
affirm |
post |
User activates an item (mouse click or keyboard). |
open / close use sequence pre so the perceptual signal lands at the gesture moment, before the structural flip (the content is still in DOM, the cascade matches [data-dropdown-menu-content]). commit-select fires from MenuItem onclick and onkeydown with e.currentTarget as fallback target.
Pack: src/uix/sema/components/dropdown-menu.ts:
open → emerge.soft (soft chime, gain 0.08).
close → emerge.exit.soft (descending pitch + gain 0.05).
commit-select → form.commit.subtle + light tap haptic. Softer than radio-group because close-emerge + select-commit + downstream-surface fire in quick succession; spreading intensity avoids double-tap fatigue.
Usage
Basic
<script>
import { DropdownMenu } from '$soma/components';
let open = $state(false);
</script>
<DropdownMenu.Provider bind:open>
<DropdownMenu.Trigger>Options</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item>Edit</DropdownMenu.Item>
<DropdownMenu.Item>Duplicate</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>Delete</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Provider>
Checkbox item
<script>
let showToolbar = $state(true);
</script>
<DropdownMenu.CheckboxItem bind:checked={showToolbar}>Show Toolbar</DropdownMenu.CheckboxItem>
Radio group
<script>
let theme = $state('light');
</script>
<DropdownMenu.RadioGroup bind:value={theme}>
<DropdownMenu.RadioItem value="light">Light</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="dark">Dark</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="system">System</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Share</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Item>Email</DropdownMenu.Item>
<DropdownMenu.Item>Slack</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>