Menu
Menu renders a raised Windows 98 command menu. Compose it from MenuItem actions, MenuCheckboxItem toggles, MenuRadioGroup / MenuRadioItem single-selects, MenuLabel headings, MenuShortcut accelerator hints, MenuSeparator dividers, and MenuSub side-expanding submenus. These same items are what ContextMenu and the window WindowMenuBar render, so their behavior is shared across every menu surface.
Composition
Use the following composition to build a Menu:
Menu
├── MenuItem
│ └── MenuShortcut
├── MenuCheckboxItem
├── MenuSeparator
├── MenuLabel
├── MenuRadioGroup
│ ├── MenuRadioItem
│ └── MenuRadioItem
├── MenuSeparator
└── MenuSub
├── MenuSubTrigger
└── MenuSubContent
├── MenuItem
└── MenuItemIcons are composed as children — render your own element (<img>, <svg>, …) as the first child of a MenuItem or MenuSubTrigger and size it however you like. Use reserveIconSpace on plain rows so their labels stay aligned with icon-bearing or checkable siblings.
Props
| Export | Key props | Description |
|---|---|---|
Menu | maxHeight, HTML menu props | Root command list with role="menu". |
MenuItem | disabled, selected, reserveIconSpace | Interactive or disabled menu row. Compose an icon by rendering it as the first child. |
MenuCheckboxItem | checked, onCheckedChange, disabled | Toggle row with a check indicator (role="menuitemcheckbox"). |
MenuRadioGroup | value, onValueChange | Single-selection wrapper for MenuRadioItems. |
MenuRadioItem | value, disabled | Selectable row with a bullet indicator (role="menuitemradio"). |
MenuLabel | HTML li props | Non-interactive group heading. |
MenuShortcut | HTML span props | Right-aligned accelerator hint placed inside an item. |
MenuSeparator | HTML li props | Horizontal divider between command groups. |
MenuSub | open, onOpenChange, hoverOpenDelay, hoverCloseDelay | Owns submenu open state and hover timers. |
MenuSubTrigger | disabled, reserveIconSpace | Menu row that opens a submenu, with a built-in right-side chevron. Compose an icon by rendering it as the first child. |
MenuSubContent | sideOffset, plus Menu props | Portal-rendered submenu positioned to the right of the trigger, flipping to the left at the viewport edge. |
Examples
Command menu
import { Menu, MenuItem, MenuSeparator } from '@murasaki-io/react98'
export function MenuBasicDemo(): React.ReactElement {
return (
<Menu className="w-52">
<MenuItem>
<span aria-hidden="true" className="flex size-4 items-center justify-center">*</span>
New Window
</MenuItem>
<MenuItem reserveIconSpace>Open</MenuItem>
<MenuItem reserveIconSpace selected>Save</MenuItem>
<MenuSeparator />
<MenuItem reserveIconSpace disabled>Delete</MenuItem>
<MenuItem reserveIconSpace>Properties</MenuItem>
</Menu>
)
}Icons, checkboxes, radios, and shortcuts
import { Menu, MenuCheckboxItem, MenuItem, MenuLabel, MenuRadioGroup, MenuRadioItem, MenuSeparator, MenuShortcut } from '@murasaki-io/react98'
import { useState } from 'react'
function FileIcon(): React.ReactElement {
return (
<svg aria-hidden="true" viewBox="0 0 12 14" width="12" height="14" shapeRendering="crispEdges">
<path d="M1 0h7v4h4v10H1z" fill="var(--window)" />
<path d="M8 0l4 4H8z" fill="var(--button-shadow)" />
<path d="M1 0h7v4h4v10H1z" fill="none" stroke="var(--menu-text)" />
</svg>
)
}
export function MenuCheckableDemo(): React.ReactElement {
const [wrap, setWrap] = useState(true)
const [statusBar, setStatusBar] = useState(true)
const [view, setView] = useState('details')
return (
<Menu className="w-56">
<MenuItem>
<FileIcon />
New
<MenuShortcut>Ctrl+N</MenuShortcut>
</MenuItem>
<MenuItem reserveIconSpace>
Open…
<MenuShortcut>Ctrl+O</MenuShortcut>
</MenuItem>
<MenuSeparator />
<MenuCheckboxItem checked={wrap} onCheckedChange={setWrap}>Word Wrap</MenuCheckboxItem>
<MenuCheckboxItem checked={statusBar} onCheckedChange={setStatusBar}>Status Bar</MenuCheckboxItem>
<MenuSeparator />
<MenuLabel>View</MenuLabel>
<MenuRadioGroup value={view} onValueChange={setView}>
<MenuRadioItem value="icons">Large Icons</MenuRadioItem>
<MenuRadioItem value="list">List</MenuRadioItem>
<MenuRadioItem value="details">Details</MenuRadioItem>
</MenuRadioGroup>
</Menu>
)
}Submenus
import {
Menu,
MenuItem,
MenuSeparator,
MenuSub,
MenuSubContent,
MenuSubTrigger,
} from '@murasaki-io/react98'
export function MenuSubmenuDemo(): React.ReactElement {
return (
<Menu className="w-56">
<MenuItem reserveIconSpace>New</MenuItem>
<MenuItem reserveIconSpace>Open</MenuItem>
<MenuSeparator />
<MenuSub>
<MenuSubTrigger reserveIconSpace>Send To</MenuSubTrigger>
<MenuSubContent>
<MenuItem reserveIconSpace>Desktop</MenuItem>
<MenuItem reserveIconSpace>Mail Recipient</MenuItem>
<MenuItem reserveIconSpace>My Documents</MenuItem>
</MenuSubContent>
</MenuSub>
<MenuSub>
<MenuSubTrigger reserveIconSpace>Recent</MenuSubTrigger>
<MenuSubContent>
<MenuItem reserveIconSpace>Readme.txt</MenuItem>
<MenuItem reserveIconSpace>Notes.txt</MenuItem>
<MenuItem reserveIconSpace disabled>(Nothing else)</MenuItem>
</MenuSubContent>
</MenuSub>
<MenuSeparator />
<MenuItem reserveIconSpace>Exit</MenuItem>
</Menu>
)
}Scroll-arrow steppers
import { Menu, MenuItem, MenuSeparator } from '@murasaki-io/react98'
import { Fragment } from 'react'
const ITEMS = [
'New',
'Open',
'Save',
'Save As...',
'Close',
'Print',
'Print Preview',
'Page Setup',
'Properties',
'Send To',
'Recent Files',
'Workspace',
'Import',
'Export',
'Convert',
'Compress',
'Share',
'Backup',
'Restore',
'Exit',
]
export function MenuScrollArrowsDemo(): React.ReactElement {
return (
<Menu className="w-52" maxHeight={140}>
{ITEMS.map((label, index) => (
<Fragment key={label}>
<MenuItem reserveIconSpace>{label}</MenuItem>
{index === 4 && <MenuSeparator />}
</Fragment>
))}
</Menu>
)
}Viewport edge detection
import {
Menu,
MenuItem,
MenuSeparator,
MenuSub,
MenuSubContent,
MenuSubTrigger,
} from '@murasaki-io/react98'
import { Fragment, useRef } from 'react'
const ACCESSORIES = [
'Notepad',
'Calculator',
'Paint',
'WordPad',
'Accessibility',
'Address Book',
'Backup',
'CD Player',
'Character Map',
'Clipboard Viewer',
'Command Prompt',
'Disk Cleanup',
'HyperTerminal',
'Magnifier',
'Media Player',
'NetMeeting',
'Phone Dialer',
'Sound Recorder',
'System Information',
'Volume Control',
]
const EDGE_MENUS = [
{ id: 'top-left', label: 'Top left', style: { left: 12, top: 12 } },
{ id: 'top-right', label: 'Top right', style: { right: 12, top: 12 } },
{ id: 'bottom-left', label: 'Bottom left', style: { bottom: 12, left: 12 } },
{ id: 'bottom-right', label: 'Bottom right', style: { bottom: 12, right: 12 } },
] as const
function EdgeMenu({
boundaryRef,
label,
}: {
boundaryRef: React.RefObject<HTMLDivElement | null>
label: string
}): React.ReactElement {
return (
<Menu style={{ width: 152 }}>
<MenuItem reserveIconSpace disabled>{label}</MenuItem>
<MenuSeparator />
<MenuSub hoverOpenDelay={0} hoverCloseDelay={120}>
<MenuSubTrigger reserveIconSpace>Accessories</MenuSubTrigger>
<MenuSubContent boundaryRef={boundaryRef} estimatedHeight={560} estimatedWidth={176}>
{ACCESSORIES.map((item, index) => (
<Fragment key={item}>
<MenuItem reserveIconSpace>{item}</MenuItem>
{index === 3 && <MenuSeparator />}
</Fragment>
))}
</MenuSubContent>
</MenuSub>
<MenuSub hoverOpenDelay={0} hoverCloseDelay={120}>
<MenuSubTrigger reserveIconSpace>Recent</MenuSubTrigger>
<MenuSubContent boundaryRef={boundaryRef} estimatedWidth={176}>
<MenuItem reserveIconSpace>Readme.txt</MenuItem>
<MenuItem reserveIconSpace>Notes.txt</MenuItem>
<MenuItem reserveIconSpace>Budget.xls</MenuItem>
</MenuSubContent>
</MenuSub>
<MenuSeparator />
<MenuItem reserveIconSpace>Exit</MenuItem>
</Menu>
)
}
export function MenuEdgeTestDemo(): React.ReactElement {
const boundaryRef = useRef<HTMLDivElement>(null)
return (
<div
ref={boundaryRef}
className="relative w-full max-w-[720px] min-h-[460px] overflow-hidden p-3 bg-(--window) shadow-(--shadow-border-field)"
>
{EDGE_MENUS.map(menu => (
<div key={menu.id} className="absolute" style={menu.style}>
<EdgeMenu boundaryRef={boundaryRef} label={menu.label} />
</div>
))}
<div
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[176px] text-center text-[12px] text-(--gray-text)"
>
Hover Accessories
</div>
</div>
)
}Icons
MenuItem renders icon in a fixed 16px gutter that constrains oversized images (max-width/max-height + object-contain), so pixel-art icons stay centered and aligned with the label instead of clipping. Use reserveIconSpace on sibling items so their text lines up with iconned rows.
ARIA
Menu uses role="menu". Items use role="menuitem", role="menuitemcheckbox", or role="menuitemradio"; toggles and radios expose aria-checked, and disabled items expose aria-disabled. MenuSubTrigger adds aria-haspopup="menu" and toggles aria-expanded. All enabled item roles participate in roving focus and typeahead.
Keyboard
ArrowUp and ArrowDown move focus between enabled items (including checkbox and radio items) and wrap at the ends. Home and End move to the first and last enabled item. Typing printable characters moves focus to the next enabled item whose text starts with the typed buffer. Enter and Space activate the focused item — toggling a MenuCheckboxItem, selecting a MenuRadioItem, or invoking a MenuItem. On a MenuSubTrigger, ArrowRight (or Enter / Space) opens the submenu; inside MenuSubContent, ArrowLeft closes it. Escape dismisses the innermost open submenu.
SSR
Keep selected, checked, disabled, and icon content stable between server and client so focusable items hydrate with the same order and labels. MenuSubContent portals on mount, so it renders client-side only.