better-mui-menu
v1.3.4
Published
MUI Menu enhancement for deeply nested menus with keyboard navigation and accessibility in mind.
Downloads
1,176
Readme
better-mui-menu
better-mui-menu is a lightweight drop-in for Material UI that keeps a normal Menu structure while adding nested menus and full keyboard accessibility so nothing breaks audits or expectations in an MUI app.
Live demo
Try it:

Features
- Unlimited nesting – describe every submenu with an
itemsarray andbetter-mui-menurendersNestedMenuItempoppers that stay synchronized with their parents. - Keyboard accessible – arrow keys, Enter/Space, and Escape behave like desktop menus, while focus management and ARIA attributes make the tree readable by assistive tech.
- Data-driven API – you keep work in a single
MenuItem[]list; leaves, dividers, and nested branches all live alongside each other.
Installation
npm install better-mui-menuSince the component renders Material UI primitives, also install the peer dependencies:
npm install @mui/material @emotion/react @emotion/styled @mui/icons-materialThe @mui/icons-material peer is optional unless you use startIcon/endIcon helpers but we list it so consumers don’t need extra typings setup.
Usage
import { useState } from 'react'
import Button from '@mui/material/Button'
import Cloud from '@mui/icons-material/Cloud'
import Save from '@mui/icons-material/Save'
import { Menu, type MenuItem } from 'better-mui-menu'
const menuItems: MenuItem[] = [
{
id: 'save',
label: 'Save',
startIcon: Save,
onClick: () => console.log('Save action'),
},
{ type: 'divider' },
{
label: 'Cloud actions',
startIcon: Cloud,
items: [
{ label: 'Upload', onClick: () => console.log('Upload') },
{ label: 'Download', onClick: () => console.log('Download') },
],
},
]
export function FileMenu() {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null)
return (
<>
<Button variant="contained" onClick={event => setAnchorEl(event.currentTarget)}>
Open file menu
</Button>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
items={menuItems}
/>
</>
)
}Items shape
MenuItem extends @mui/material/MenuItemProps (excluding children) so you can still pass dense, disabled, divider, aria-selected, etc. The better-mui-menu shape adds:
type?: 'item' | 'divider'– render aDividerwhen'divider'is supplied.id?: string– optional stable ID for ARIA attributes; one is generated automatically otherwise.label: ReactNode– the label shown in the menu row.startIcon/endIcon– pass anySvgIconComponentto display icons consistently with Material UI.items?: MenuItem[]– nested entries that render as submenus.onClick?: MenuItemProps['onClick']– leaves bubble clicks and close the menu stack so the consumer can handle the action.
Accessibility & interactions
- Nested menus render in
Popperinstances positionedright-startfrom the trigger and fade in/out with a shared transition helper. - Hover keeps a submenu open while the mouse moves between trigger and popper; leaving the area closes the branch.
- Keyboard navigation covers
ArrowUp/ArrowDownthrough siblings,ArrowRightorEnter/Spaceto open children,ArrowLeftto back out, andEscapeto dismiss every layer. - ARIA helpers (
aria-haspopup,aria-controls,aria-expanded,aria-labelledby) are wired automatically so assistive tech understands the structure.
Development
The library lives inside package/better-mui-menu.
npm run dev– rebuildssrcintodistwithtsup --watch.npm run build– creates production bundles ready for publication.npm run test– runs the Jest suite located atsrc/**/*.test.{ts,tsx}.npm run test:coverage– runs tests with coverage and writes reports tocoverage/.
From the repository root you can use npm run dev:lib and npm run dev:demo together so the demo app consumes the rebuilt workspace link. Keep npm run dev (or npm run build) running before refreshing the demo because the Vite app imports the package via file:.
