@affino/menu-core
v1.0.0-alpha.8
Published
Framework-agnostic headless menu engine with smart mouse prediction, keyboard navigation, and nested submenus
Maintainers
Readme
@affino/menu-core
🚧 Status: Beta — API is stable, seeking feedback before 1.0 release
A framework-agnostic, type-safe menu/dropdown core engine with intelligent mouse prediction, nested submenu support, and comprehensive accessibility features.
The Problem It Solves
Building accessible, nested dropdown menus is hard:
- Mouse prediction — Users moving diagonally toward a submenu accidentally trigger other items
- Framework lock-in — Most libraries tie you to React, Vue, or specific UI frameworks
- Accessibility — Proper ARIA attributes, keyboard navigation, and focus management require expertise
- Bundle size — Dragging in full component libraries for just a menu
@affino/menu-core solves this by providing just the behavior logic in ~8KB. You control the HTML and CSS, we handle the complex interaction patterns.
Architecture Overview
MenuCore
├── State Management
│ ├── subscribe() → Observable pattern
│ ├── getSnapshot() → Current state
│ └── getTree() → Shared tree instance
│ ├── openPath[] → Active menu hierarchy
│ └── activePath[] → Highlighted item chain
│
├── Item Registry
│ ├── registerItem() → Add menu items
│ ├── highlight() → Focus management
│ ├── moveFocus() → Keyboard navigation
│ └── select() → Item activation
│
├── Props Binding
│ ├── getTriggerProps() → Button/anchor attributes
│ ├── getPanelProps() → Menu container attributes
│ └── getItemProps() → Individual item attributes
│
├── Positioning
│ └── computePosition() → Smart viewport-aware placement
│
└── Lifecycle
├── open() / close() / toggle()
├── cancelPendingClose()
└── destroy()Why @affino/menu-core?
Most menu libraries mix logic and UI, tying you to a specific framework. This package isolates only the behavior layer, allowing you to build:
- ✅ Vue menus (see
@affino/menu-vue) - ✅ React menus
- ✅ Svelte menus
- ✅ Web Components
- ✅ Canvas / Pixi.js menus
- ✅ Terminal UIs
- ✅ Any custom renderer
Libraries like Radix, HeadlessUI, and Mantine are framework-specific. This is pure TypeScript logic — bring your own UI.
Key features
| Feature | @affino/menu-core | |---------|------------------| | Framework | Any (headless) | | Bundle Size | ~8KB | | Mouse Prediction | ✅ Built-in | | Nested Submenus | ✅ Unlimited | | Custom Renderers | ✅ Canvas/GL/etc | | Bring Your CSS | ✅ 100% control | | TypeScript | ✅ Full |
Use this when:
- You need framework flexibility (or plan to migrate frameworks)
- Bundle size matters (mobile-first, performance budgets)
- You want diagonal mouse prediction (better UX for nested menus)
- You're building custom renderers (game engines, canvas apps, terminal UIs)
Use alternatives when:
- You're all-in on React and want pre-styled components (Radix, Mantine)
- You prefer component libraries over headless logic (Mantine, Ant Design)
- You need zero configuration and don't mind framework lock-in
Performance
- ✅ Event-driven updates — Core emits updates only when state actually changes
- ✅ No virtual DOM — No diffing overhead or reconciliation
- ✅ No automatic re-renders — Your adapter controls when/how to update UI
- ✅ Efficient subscriptions — Granular state observation with instant snapshots
- ✅ Zero dependencies — Minimal bundle size (~8KB minified)
Compared to React-based solutions, this approach eliminates:
- Virtual DOM diffing on every state change
- Framework-level re-render cycles
- Component tree reconciliation overhead
You get direct state updates and full control over rendering strategy.
Features
- 🎯 Framework Agnostic — Pure TypeScript core logic, integrate with any UI framework
- ♿ Accessible by Default — Full ARIA support with keyboard navigation (Arrow keys, Home, End, Enter, Escape)
- 🧠 Smart Mouse Prediction — Intelligently predicts user intent when hovering toward submenus
- 🪆 Nested Submenus — Unlimited nesting depth with coordinated open/close timing
- ⌨️ Keyboard Navigation — Complete keyboard control with configurable focus looping
- 📍 Intelligent Positioning — Automatic placement and alignment with viewport collision detection
- ⚡ Performance Optimized — Minimal re-renders with efficient state subscriptions
- 🎨 Fully Typed — Complete TypeScript definitions for all APIs
Installation
npm install @affino/menu-core
# or
pnpm add @affino/menu-core
# or
yarn add @affino/menu-coreQuick Start
Three Steps to a Working Menu
HTML:
<button id="menu-trigger">Open Menu</button>
<div id="menu-panel" hidden>
<div data-item="save">Save</div>
<div data-item="export">Export</div>
<div data-item="delete">Delete</div>
</div>JavaScript:
import { MenuCore } from '@affino/menu-core'
const menu = new MenuCore()
const trigger = document.querySelector('#menu-trigger')
const panel = document.querySelector('#menu-panel')
// 1. Connect the trigger button
trigger.addEventListener('click', () => menu.toggle())
// 2. Register menu items
panel.querySelectorAll('[data-item]').forEach(item => {
const id = item.dataset.item
menu.registerItem(id)
item.addEventListener('click', () => {
console.log('Selected:', id)
menu.select(id)
})
})
// 3. Show/hide the panel
menu.subscribe(state => {
panel.hidden = !state.open
})That's it! Three clear steps, no framework required. The core handles:
- ✅ Keyboard navigation (arrows, enter, escape)
- ✅ ARIA attributes for screen readers
- ✅ Focus management
- ✅ Open/close coordination
Full Example with Options
import { MenuCore } from '@affino/menu-core'
// Create a menu instance
const menu = new MenuCore({
id: 'main-menu',
openDelay: 80,
closeDelay: 150,
closeOnSelect: true,
loopFocus: true
}, {
onOpen: (menuId) => console.log('Menu opened:', menuId),
onClose: (menuId) => console.log('Menu closed:', menuId),
onSelect: (itemId, menuId) => console.log('Item selected:', itemId)
})
// Subscribe to state changes
const subscription = menu.subscribe((state) => {
console.log('Menu state:', state.open, state.activeItemId)
})
// Register menu items
const unregisterItem1 = menu.registerItem('item-1')
const unregisterItem2 = menu.registerItem('item-2', { disabled: true })
// Get props to bind to your UI elements
const triggerProps = menu.getTriggerProps()
const panelProps = menu.getPanelProps()
const item1Props = menu.getItemProps('item-1')
// Control the menu programmatically
menu.open('programmatic')
menu.close('programmatic')
menu.toggle()
// Cleanup
subscription.unsubscribe()
unregisterItem1()
unregisterItem2()
menu.destroy()Live Examples
Try it yourself in under 30 seconds:
🚀 Vanilla JS Demo →
Pure JavaScript — no build step, no framework🎨 Vue 3 Adapter → Install
@affino/menu-vuefor ready-made Vue components⚛️ React Adapter → Install
@affino/menu-reactfor ready-made React hooks
Adapter Guide
Want to create an adapter for your favorite framework? Here's how:
Step 1: Bind getTriggerProps()
const triggerProps = menu.getTriggerProps()
// Apply these to your trigger element (button/anchor)Step 2: Bind getPanelProps()
const panelProps = menu.getPanelProps()
// Apply these to your menu panel containerStep 3: Register items and bind getItemProps()
items.forEach(item => {
menu.registerItem(item.id)
const itemProps = menu.getItemProps(item.id)
// Apply to each menu item element
})Step 4: Handle pointer tracking (for submenus)
// On pointermove events
submenu.recordPointer({ x: event.clientX, y: event.clientY })Step 5: Use computePosition() for dynamic placement
const position = menu.computePosition(
triggerRect,
panelRect,
{ placement: 'bottom', align: 'start' }
)
// Apply position.left and position.top to panelStep 6: Subscribe to state
menu.subscribe(state => {
// Update your framework's reactive state
updateYourFrameworkState(state)
})Core Concepts
State Management
Menu state is managed through an observable pattern. Subscribe to state changes and react to updates:
interface MenuState {
open: boolean
activeItemId: string | null
}
const subscription = menu.subscribe((state) => {
// Update your UI based on state changes
updateUI(state)
})Props System
The core provides props objects that match WAI-ARIA Menu pattern specifications. Simply spread these onto your UI elements:
const triggerProps = menu.getTriggerProps()
// Returns: { id, role, tabIndex, aria-*, onClick, onKeyDown, ... }
const panelProps = menu.getPanelProps()
// Returns: { id, role, tabIndex, aria-labelledby, onKeyDown, ... }
const itemProps = menu.getItemProps('item-id')
// Returns: { id, role, tabIndex, aria-disabled, data-state, onClick, ... }Mouse Prediction
The Problem:
When users move their cursor diagonally toward a submenu, they briefly hover over other menu items. Without prediction, this closes the submenu they're trying to reach—super frustrating!
The Solution:
The core tracks mouse movement and intelligently keeps submenus open when it detects diagonal motion toward them. Inspired by Amazon's mega menus and Stripe's navigation.
// The defaults work great for 90% of cases:
const menu = new MenuCore() // ✅ Just works!
// Need to tune for trackpads or dense menus?
const menu = new MenuCore({
mousePrediction: {
verticalTolerance: 30, // More forgiving diagonal movement
headingThreshold: 0.2 // Less strict direction checking
}
})When to adjust:
- Trackpad users → Increase
verticalTolerance(30-40px) - Very dense menus → Lower
headingThreshold(0.1-0.2) - High-precision mice → Keep defaults
📖 Full tuning guide: docs/mouse-prediction.md
Nested Submenus
Create child menus with automatic coordination:
import { SubmenuCore } from '@affino/menu-core'
const parentMenu = new MenuCore({ id: 'parent' })
const parentTree = parentMenu.getTree()
const submenu = new SubmenuCore(
parentMenu,
{
id: 'submenu-1',
parentItemId: 'parent-item-with-submenu',
openDelay: 100,
closeDelay: 150
},
{
onOpen: (menuId) => console.log('Submenu opened:', menuId)
}
)
// Submenus inherit parent tree for coordinated behavior
submenu.getTree() === parentTree // trueAPI Reference
MenuCore
Constructor
new MenuCore(options?: MenuOptions, callbacks?: MenuCallbacks)Options:
id?: string— Unique menu identifier (auto-generated if omitted)openDelay?: number— Delay before opening on hover (default:80ms)closeDelay?: number— Delay before closing on hover out (default:150ms)closeOnSelect?: boolean— Auto-close when item selected (default:true)loopFocus?: boolean— Wrap focus at list boundaries (default:true)mousePrediction?: MousePredictionConfig— Mouse prediction settings
Callbacks:
onOpen?: (menuId: string) => voidonClose?: (menuId: string) => voidonSelect?: (itemId: string, menuId: string) => voidonHighlight?: (itemId: string | null, menuId: string) => voidonPositionChange?: (menuId: string, position: PositionResult) => void
Methods
State Control
menu.open(reason?: 'pointer' | 'keyboard' | 'programmatic'): void
menu.close(reason?: 'pointer' | 'keyboard' | 'programmatic'): void
menu.toggle(): voidState Subscription
menu.subscribe(listener: (state: MenuState) => void): Subscription
menu.getSnapshot(): MenuStateItem Registry
menu.registerItem(id: string, options?: { disabled?: boolean }): () => void
menu.highlight(id: string | null): void
menu.moveFocus(delta: 1 | -1): void
menu.select(id: string): voidProps Getters
menu.getTriggerProps(): TriggerProps
menu.getPanelProps(): PanelProps
menu.getItemProps(id: string): ItemPropsPositioning
menu.computePosition(
anchor: Rect,
panel: Rect,
options?: PositionOptions
): PositionResultPositionOptions:
gutter?: number— Space between anchor and panel (default:4px)viewportPadding?: number— Minimum viewport margin (default:8px)placement?: 'left' | 'right' | 'top' | 'bottom' | 'auto'(default:'bottom')align?: 'start' | 'center' | 'end' | 'auto'(default:'start')viewportWidth?: number— Custom viewport widthviewportHeight?: number— Custom viewport height
Tree & Cleanup
menu.getTree(): MenuTree
menu.cancelPendingClose(): void
menu.destroy(): voidSubmenuCore
Extends MenuCore with parent-child coordination.
new SubmenuCore(
parent: MenuCore,
options: SubmenuOptions,
callbacks?: MenuCallbacks
)Additional Options:
parentItemId: string— ID of the parent menu item that triggers this submenu
Additional Methods:
submenu.setTriggerRect(rect: Rect | null): void
submenu.setPanelRect(rect: Rect | null): void
submenu.recordPointer(point: { x: number; y: number }): voidAdvanced Examples
Custom Framework Integration
import { MenuCore } from '@affino/menu-core'
class MyMenuComponent {
private core: MenuCore
private unsubscribe: () => void
constructor(element: HTMLElement) {
this.core = new MenuCore({
id: element.id || undefined,
closeOnSelect: true
}, {
onOpen: () => this.render(),
onClose: () => this.render(),
onHighlight: () => this.render()
})
this.unsubscribe = this.core.subscribe((state) => {
this.updateDOM(state)
})
this.bindEvents(element)
}
private bindEvents(element: HTMLElement) {
const trigger = element.querySelector('[data-trigger]')!
const panel = element.querySelector('[data-panel]')!
const items = element.querySelectorAll('[data-item]')
const triggerProps = this.core.getTriggerProps()
Object.entries(triggerProps).forEach(([key, value]) => {
if (key.startsWith('on')) {
trigger.addEventListener(key.slice(2).toLowerCase(), value)
} else if (key.startsWith('aria-') || key === 'role' || key === 'tabIndex') {
trigger.setAttribute(key, String(value))
}
})
items.forEach((item) => {
const itemId = item.getAttribute('data-item')!
const unregister = this.core.registerItem(itemId)
const itemProps = this.core.getItemProps(itemId)
// Bind item props similarly...
})
}
destroy() {
this.unsubscribe()
this.core.destroy()
}
}Dynamic Positioning
import { computePosition } from '@affino/menu-core'
function updateMenuPosition(
triggerEl: HTMLElement,
panelEl: HTMLElement
) {
const triggerRect = triggerEl.getBoundingClientRect()
const panelRect = panelEl.getBoundingClientRect()
const position = computePosition(triggerRect, panelRect, {
placement: 'bottom',
align: 'start',
gutter: 8,
viewportPadding: 16,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight
})
panelEl.style.left = `${position.left}px`
panelEl.style.top = `${position.top}px`
// Update based on final placement
panelEl.dataset.placement = position.placement
panelEl.dataset.align = position.align
}Multi-Level Menu Tree
const rootMenu = new MenuCore({ id: 'root' })
const tree = rootMenu.getTree()
// First level submenu
const submenu1 = new SubmenuCore(rootMenu, {
id: 'submenu-1',
parentItemId: 'root-item-1'
})
// Second level submenu
const submenu2 = new SubmenuCore(submenu1, {
id: 'submenu-2',
parentItemId: 'submenu-1-item-3'
})
// All share the same tree instance
tree.subscribe('root', (state) => {
console.log('Open path:', state.openPath)
console.log('Active path:', state.activePath)
})TypeScript Support
All types are exported for full type safety:
import type {
MenuCore,
SubmenuCore,
MenuOptions,
MenuCallbacks,
MenuState,
TriggerProps,
PanelProps,
ItemProps,
PositionOptions,
PositionResult,
MousePredictionConfig,
Rect,
Point
} from '@affino/menu-core'Best Practices
- Always cleanup — Call
destroy()and unsubscribe when component unmounts - Register items early — Register menu items before opening to ensure proper focus management
- Use ref callbacks — Bind element refs via callbacks to handle dynamic DOM updates
- Debounce positioning — Use ResizeObserver with debouncing for position recalculation
- Test accessibility — Verify keyboard navigation and screen reader announcements
- Handle edge cases — Account for scrollable containers and CSS transforms in positioning
Browser Support
- Modern browsers with ES2020+ support
- TypeScript 5.0+
- No polyfills required for core functionality
Troubleshooting
Menu doesn't close when clicking outside
Problem: The core doesn't automatically handle click-outside detection.
Solution: Add this to your adapter:
document.addEventListener('click', (e) => {
if (!panel.contains(e.target) && !trigger.contains(e.target)) {
menu.close('programmatic')
}
})Keyboard navigation not working
Problem: Forgot to apply getPanelProps() to the menu container.
Solution: The panel needs role="menu" and keyboard event handlers:
const panelProps = menu.getPanelProps()
Object.assign(panel, panelProps)Menu position is wrong with CSS transforms
Problem: getBoundingClientRect() returns viewport coordinates, but transforms affect positioning.
Solution: Pass custom viewport dimensions or adjust for transform scale:
const position = menu.computePosition(triggerRect, panelRect, {
viewportWidth: window.innerWidth / scale,
viewportHeight: window.innerHeight / scale
})Submenu closes too quickly
Problem: Default closeDelay is too short for your use case.
Solution: Increase the delay:
const submenu = new SubmenuCore(parent, {
closeDelay: 300 // Wait 300ms before closing (default: 150ms)
})TypeScript errors with props
Problem: Spreading props onto elements with strict types.
Solution: Cast or use type assertions:
const props = menu.getTriggerProps()
Object.assign(trigger as any, props)
// Or: {...props as React.ButtonHTMLAttributes<HTMLButtonElement>}Contributing
Feedback and contributions are welcome! This project is in beta and we're actively seeking:
- 🐛 Bug reports
- 💡 Feature suggestions
- 🧪 Real-world usage examples
- 📦 Framework adapters (React, Svelte, etc.)
License
MIT
Related Packages:
- [
@affino/menu-vue] — Vue 3 components built on this core - [
@affino/menu-react] — React components built on this core
