Usage
@prepared911/ui/user-menu from its subpath. Load @prepared911/ui/styles.css once at the document root.
UserMenu is the account menu behind a person's avatar in an app's top bar, and every app draws the same one: the Figma account menu. The trigger is the avatar (photo or initials). The panel leads with an identity header, then a rule, an Appearance section (Theme, Reduce motion, Reset layout, Zoom), a Sound section (Headset volume, Application sound), a rule, and Sign out. The web app leaves out Zoom (showZoom={false}); everything else is the same as the desktop app.
DesktopDefault and WebDefault stories show the two versions.
triggerLabel is the accessible name of the trigger. Write it to include the person's name, for example "Dana Levi, account menu".
To open the menu from another control, such as the preferences icon button of a top bar, pass it as trigger instead and drop triggerLabel. The control keeps its own name; the menu adds the button attributes and returns focus to it on close. See the Navbar contract (NB-16).
showName the trigger is the avatar alone. With showName it also draws the name (one line, capped at 12rem) and a chevron that turns while the menu is open, from 640px of viewport width up. The accessible name is the same either way and the button element is the same, so a window that resizes across 640px keeps focus.avatarSrc. Without it, or when it fails to load, the avatar shows initials, which default to the first letter of each of the first two words of name.defaultOpen or a controlled open does not take focus.title.items is an optional slot, off by default, for extra account actions such as Diagnostics for a super admin: each has id, label, optional icon and disabled, and onSelect(). The rows sit after the last rule, just before Sign out; a press runs onSelect, closes the menu and returns focus to the trigger. Destinations and submenus belong in the app's own Menu.align="end") and opens below it (side="block-end").packages/ui/src/components/menu/CONTRACT.md).appearanceSectionLabel and soundSectionLabel put each section under its visible heading; a section with no rows is not drawn, so an app with no sound settings shows no Sound section.
| Row | Section | Props | Reports |
|---|---|---|---|
| Theme | Appearance | appearanceLabels, appearance, defaultAppearance, onAppearanceChange | AppearanceMode.Light, Dark or System |
| Reduce motion | Appearance | reduceMotionLabel, reduceMotion, defaultReduceMotion, onReduceMotionChange | true or false |
| Zoom | Appearance | showZoom, zoomLabel, zoom, onZoomChange | Percent from 80 to 150. Row only when showZoom is true |
| Headset volume | Sound | headsetVolumeLabel, headsetVolume, onHeadsetVolumeChange | Integer from 1 to 15 |
| Application sound | Sound | applicationSoundLabel, applicationSound, onApplicationSoundChange | Integer from 0 to 15 |
| Reset layout | Appearance, after Reduce motion | onResetLayout, resetLayoutLabel, resetLayoutButtonLabel | Host callback only. The row shows its label and an outlined Reset chip whenever onResetLayout is set; the whole row is one menuitem, so a press, Enter or Space runs it and the menu stays open. UserMenu does not confirm; the host confirms or offers undo before applying the reset |
menuitemcheckbox with aria-checked that draws the design system's Switch track at the inline end. The whole row toggles on a press, Enter or Space, and the menu stays open. The track is a picture: it has no input and no tab stop, so no control nests inside the row. An uncontrolled preference survives closing and reopening the menu.
Each slider row matches the Figma layout: the row label and a numeric readout (percent for Zoom, plain number for volume and sound) sit above the track. UserMenu uses Slider with showValue inside a full-width MenuControlRow (Menu MN-46). The slider is one stop of the menu (Slider SL-26): vertical arrows move between rows; horizontal arrows and Page Up and Page Down change the value.
Set showZoom from the shell when the Zoom row should appear, for example const showZoom = useCurrentPlatform()?.runtime === "desktop". UserMenu does not inspect the user agent.
The app owns the effect. A minimal wiring:
data-motion="reduced" on <html>, MotionPreference and applyMotionPreference()) is owned by the motion preference work; UserMenu does not import it. Pass details.cancel() in a change handler to refuse a change. Wire zoom, headset volume and application sound to your shell or media settings the same way.
Sign out is not a destructive action (owner ruling): it uses the neutral default item styling, with its leading icon, and takes no destructive intent, so it looks like any other row.
onSignOut may return a promise. While it is pending the row shows a spinner, reads signingOutLabel, is aria-busy, and ignores further presses; the menu closes when the promise settles, resolved or rejected, and focus returns to the trigger. A rejection is not caught: handle the error in onSignOut. A callback that returns nothing closes the menu at once.
open, defaultOpen and onOpenChange(open, details) follow the design system's controlled and uncontrolled rules. details.reason is "trigger-press", "item-press", "focus-out" or "light-dismiss".
Below 640px only the avatar shows and the panel stays a popover. A bottom sheet is a follow-up.