A fully-customizable, declarative menu constructor for React. Author menus as
typed configuration objects; the runtime handles positioning (Floating UI),
keyboard navigation, virtualization (@tanstack/react-virtual), drag-reorder
(@dnd-kit), sub-menu stacking, persistence, lifecycle, and theming.
▶ Live demo: every example below runs in the browser, with live config editing.
![]() Command palette |
![]() Date + time picker |
![]() Media picker (tabs + grid) |
![]() Nested query builder |
![]() Color picker |
![]() Multi-page settings |
┌─────────────────────────────────────────────────────────────────────────┐
│ defineMenu({ chrome, position, body, sub-menus, keyboard, storage }) │
├─────────────────────────────────────────────────────────────────────────┤
│ Body: list │ grid │ form │ custom │ composed (mix anything) │
│ Rows: item · section · divider · switch · checkbox · select-nav · │
│ color · object · participant · add · sortable · chip · … │
│ Panels: search · monthGrid · emojiGrid · tileGrid · fileDropZone · │
│ queryBuilder · slider · katex · qrCode · markdownToolbar · … │
└─────────────────────────────────────────────────────────────────────────┘
Status: pre-release. The schema is stable enough to author against; the runtime ships a meaningful subset of row / panel / field renderers and grows incrementally. See Open work.
bun add @react-fancy-menus/core react react-dom// app.tsx
import { MenuProvider } from '@react-fancy-menus/core';
import '@react-fancy-menus/core/runtime/runtime.css';
import { commandPalette } from './menus/commandPalette.config';
export function App() {
return (
<MenuProvider menus={[commandPalette]}>
<Toolbar />
</MenuProvider>
);
}// Toolbar.tsx
import { useRef } from 'react';
import { useMenu } from '@react-fancy-menus/core';
export function Toolbar() {
const triggerRef = useRef<HTMLButtonElement | null>(null);
const menu = useMenu();
return (
<button
ref={triggerRef}
onClick={() => menu.open('commandPalette', { element: triggerRef.current! })}
>
Open
</button>
);
}// menus/commandPalette.config.ts
import {
defineMenu, BodyKind, RowKind, SourceKind, Vertical, Horizontal,
} from '@react-fancy-menus/core';
import { FileText, Settings } from 'lucide-react';
export const commandPalette = defineMenu({
id: 'commandPalette',
position: { vertical: Vertical.Bottom, horizontal: Horizontal.Center, width: 480 },
chrome: {
filter: { placeholder: 'Type a command…', focusOnMount: true },
},
body: {
kind: BodyKind.List,
source: { kind: SourceKind.Static, items: [
{ id: 'open', name: 'Open File', icon: FileText, shortcut: '⌘O' },
{ id: 'settings', name: 'Settings', icon: Settings, shortcut: '⌘,' },
]},
rows: [{
kind: RowKind.Item,
name: (it) => it.name,
icon: (it) => ({ icon: it.icon, size: 16 }),
caption: (it) => it.shortcut,
onClick: (item, _e, ctx) => { /* … */ ctx.close(); },
}],
virtualized: true,
},
});fancy-menus/
├── packages/core/ ← the library (types + runtime)
│ ├── src/types/ ← schema, no runtime imports
│ │ ├── enums.ts ← every text constant
│ │ ├── primitives.ts ← IconParam, ButtonSpec, Vertical, …
│ │ ├── position.ts ← PositionConfig
│ │ ├── chrome.ts ← ChromeConfig + Header/Footer slots
│ │ ├── source.ts ← ItemSource (static/prop/store/async/…)
│ │ ├── row.ts ← 15 row variants
│ │ ├── panel.ts ← 22 panel variants
│ │ ├── field.ts ← form field specs
│ │ ├── body.ts ← list/grid/form/custom/composed
│ │ ├── sub-menu.ts, keyboard.ts, storage.ts, lifecycle.ts,
│ │ ├── context.ts ← MenuCtx + ProviderOptions
│ │ └── menu-config.ts ← MenuConfig + defineMenu()
│ └── src/runtime/ ← React rendering layer
│ ├── runtime.css ← canonical design-system tokens
│ ├── store.ts ← MenuStore (open stack)
│ ├── provider.tsx ← MenuProvider + useMenu / useMenuStack
│ ├── menu.tsx ← single-menu shell (chrome + body)
│ ├── menu-stack.tsx ← stack renderer
│ ├── body.tsx ← BodyKind dispatcher
│ ├── list-body.tsx ← virtualized list (+ dnd-kit)
│ ├── grid-body.tsx ← virtualized 2D grid
│ ├── form-body.tsx ← form fields
│ ├── panels.tsx ← every PanelKind renderer
│ ├── filter-input.tsx, keyboard.ts, position.ts, source.ts,
│ ├── ctx.ts, storage.ts
│ └── rows/ ← per-RowKind renderers
└── examples/playground/ ← Vite + Tailwind + shadcn/ui
├── src/menus/ ← 10 example MenuConfigs
└── scripts/ ← smoke + interaction tests
defineMenu<TData = any, TValue = any, TItem = any>({ … }): MenuConfig| field | type | what it does |
|---|---|---|
id |
string |
unique registry key (used by open(id) and sub-menu wiring) |
kind |
MenuKind |
Menu (default), Tooltip, Context, Inline |
description |
string |
shown in dev-tools / playground |
position |
PositionConfig |
where the menu opens, sizing, viewport behavior |
chrome |
ChromeConfig |
title bar, tabs, filter, dimmer, header/footer |
body |
BodyConfig |
the main content (list / grid / form / custom / composed) |
subMenus |
Record<string, SubMenuSpec> |
declarative sub-menu wiring |
keyboard |
KeyboardConfig |
nav defaults, custom shortcuts |
storage |
StorageConfig |
per-menu persistence |
lifecycle |
LifecycleConfig |
onOpen / onClose / onSelect / etc. |
group |
string |
use with closeAll(group) to close menu families together |
tags |
string[] |
metadata for analytics / dev tools |
Generic defaults are any; pass them explicitly when you want strict typing
on MenuCtx<TData> callbacks: defineMenu<MyData, MyValue>({ … }).
| field | type | default | what it does |
|---|---|---|---|
strategy |
PositionStrategy |
Fixed |
Fixed pins to viewport, Absolute flows with the document |
vertical |
Vertical |
Bottom |
Top / Center / Bottom anchor |
horizontal |
Horizontal |
Left |
Left / Center / Right anchor |
offsetX, offsetY |
number | () => number |
0 |
pixel offset from the anchor |
fixedX, fixedY |
number |
— | pin to absolute coords |
width, height |
number |
content | lock body dimensions |
minWidth, maxWidth |
number |
220 / — |
bounds |
minHeight, maxHeight |
number |
— | bounds; combine maxHeight with a virtualized body |
noFlipX, noFlipY |
boolean |
false |
disable axis flipping when the menu would overflow |
noBorderX, noBorderY |
boolean |
false |
disable axis clamping inside the viewport |
stickToElementEdge |
Edge |
— | snap to a specific edge of the trigger |
border |
{top,bottom,left,right: number} |
8 |
viewport padding for flip + shift + size |
recalcRect |
() => DOMRect | null |
— | re-evaluate the trigger rect on every pass |
rebindOnScroll |
boolean |
true |
re-position on scroll |
followAnchor |
boolean |
true |
follow trigger movement; false = compute once and freeze |
fillViewport |
boolean |
false |
auto-cap maxHeight to available space |
withArrow |
boolean |
false |
draw an arrow pointing at the trigger |
Flip uses fallbackStrategy: 'initialPlacement' — the menu only flips when
the alternate side actually frees up space, so it never "flips then clips
again". Shift always runs (with limitShift) so the menu stays attached to
the trigger inside the viewport.
| field | type | what it does |
|---|---|---|
title |
string | () => string |
title bar |
withBack |
boolean |
back arrow + onBack |
withClose |
boolean |
close X in the title |
ariaLabel |
string |
accessible name fallback when title is a function or absent |
role |
'dialog' | 'menu' | 'listbox' |
ARIA role for the shell (default dialog) |
filter |
FilterConfig |
search input in the chrome |
tabs |
TabsConfig |
tab strip; each tab owns its body |
dimmer |
DimmerMode |
Default, None, PassThrough, Visible |
passThrough |
boolean |
clicks reach the underlying page |
noAnimation |
boolean |
disable open/close animation |
noClose |
boolean |
disable dimmer-click and Escape |
header |
HeaderSlot |
replace the default title bar (SearchBar, Custom) |
footer |
FooterSlot |
Buttons, Add, JumpBar, Sidebar, Custom |
satellite |
ReactNode | () => ReactNode |
inline slot between header and body |
className |
string |
applied to the menu container |
classNameWrap |
string |
applied to the outer wrapper |
placeholder, icon, focusOnMount (default true), debounceMs (default
250), underlined, showWhen (Always / Auto), threshold, onChange.
initialTab, tabs: Array<{ id, label, icon?, body }>, rightSlot,
persist (saves active tab to storage).
Five top-level kinds — discriminated by kind:
| field | type | what it does |
|---|---|---|
source |
ItemSource |
how items are fetched |
rows |
RowSpec[] |
row variants; first matching match predicate wins |
virtualized |
boolean | { rowHeight, overscan?, measureRows? } |
required past ~100 rows |
infiniteScroll |
{ pageSize, loadMore } |
pagination on scroll-near-end |
sortable |
{ axis?, isDraggable?, onReorder, keyboardShortcut? } |
drag-reorder via dnd-kit |
selection |
{ mode, value?, isSelected?, onChange, closeOnSelect?, maxCount? } |
single / multi |
emptyState |
EmptyStatePanel |
shown when list is empty |
loading |
LoaderPanel | boolean |
overlay or inline |
onFilterChange |
(value, ctx) => void |
called when chrome filter changes |
source, columns: number | GridColumns.Auto, rowHeight, gap?,
renderCell, responsive: { minCellWidth }, virtualized?,
keyboardNav2D?, selection?, emptyState?, loading?,
acceptDrop?: { accept, multiple, onFiles }, pasteFromClipboard?,
onCellClick, onCellContextMenu, onCellLongPress, longPressMs.
fields: FieldSpec[], initialValues?, validate?, onSubmit,
submit?: ButtonSpec | null, layout?: FormLayout, debounceMs?.
render: (ctx) => ReactNode, measureHeight?: (ctx) => number. Use this
for full visual control while keeping the menu shell, positioning, keyboard,
storage, and lifecycle. A measureHeight returning 0 suppresses the body
wrapper entirely (e.g. for find-in-page where the chrome carries the UI).
sections: Array<(ListBody | GridBody | FormBody | CustomBody | PanelSpec) & { id }>,
gap?, scroll?: ComposedScroll. Stack any mix of bodies and panels
vertically.
Discriminated by kind: RowKind.*. Common base fields: match?, className?,
tooltip?, disabled?, readonly?, hidden?, skipOver?.
| RowKind | what it renders |
|---|---|
Item |
icon + name + caption + arrow + more |
Section |
uppercased section header |
Divider |
thin horizontal line |
Switch |
item + Switch toggle on the right |
Checkbox |
item + checkbox |
SelectNav |
item + caption (current value) + arrow → opens sub-menu |
Color |
colored swatch + name + active check |
Object |
rendered icon + name + type caption (+ withDescription for inline caption) |
Add |
"+" inline add row; useFilterAsName injects the current filter |
Sortable |
wraps an inner row spec; injects a drag handle into the inner row's prefix slot |
Chip |
pill-style chip with optional trailing icon (sort rules) |
FilterRule |
composite: relation icon + condition label + value preview |
Participant |
avatar + name + identity + selection check |
Empty |
empty-state pseudo-row |
Custom |
full render control (render(item, ctx)) |
SearchInput, TabBar, TileGrid, EmojiGrid, RecentStrip,
CategoryJump, FileDropZone, MonthGrid, CodeEditor, KatexPreview,
QrCode, Slider, LinkPreview, Loader, EmptyState, Error,
MarkdownToolbar, QueryBuilder (recursive AND/OR), Label, Divider,
Banner, Custom.
Text, TextArea, Switch, Checkbox, Select, Color, Date, Icon,
File, Button, Custom. Common base: name, label, description,
required, disabled, hidden, validate, defaultValue.
SourceKind |
shape |
|---|---|
Static |
{ items: TItem[] } |
Prop |
{ getItems: (data) => TItem[] } |
Store |
{ selector: () => TItem[]; deps? } |
Async |
{ pageSize, fetch: ({ offset, limit, filter, signal }) => Promise<{ items, hasMore? }>; cacheKey?; refetchOn? } |
Sections |
{ getSections: (data) => Section[] } |
Composite |
{ sources, dedupeBy? } |
menuId, trigger: SubMenuTrigger (ArrowHover, ArrowClick, MoreIcon,
RightClick, LongPress, Replace, Programmatic), longPressMs?,
getData?, passThrough?, noFlipX/Y?,
anchor: SubMenuAnchor.Parent | Item | Cursor | (ctx, item) => Element|DOMRect|null,
forwardValue?, keepOpenOnSelect?.
disabled?, navigation: KeyboardNavigation (None, Linear, Grid2D),
defaults: { closeOnEscape?, selectOnEnter?, selectOnTab?, arrowsToSubmenu?, spaceToSwitch?, backspaceToRemove?, shiftArrowToSort?, cycleWrap? },
shortcuts: ShortcutBinding[], onKeyDown(e, ctx) => boolean | void (return
true to suppress defaults).
key? (defaults to menu id), fields?: string[] (which body-state keys to
persist), adapter?: StorageAdapter, version? + migrate?.
onBeforeOpen (veto) → onMount (return cleanup) → onOpen →
onChange / onSelect / onSubmit → onBeforeClose (veto) → onClose.
onError for unhandled exceptions in any hook.
{
id, data, storage: { get, set },
open, close, closeAll, update, updateData,
position, setActive, setHover, updateOther, isOpen,
}storage?: StorageAdapter, iconRegistry?: Record<string, IconComponent>
(resolves string-named icons), theme?: ThemeTokens, locale?: LocaleStrings,
onError, onAnalytics, isAboveOverlay.
runtime.css defines the public token surface. Override on :root or under
a .dark / [data-theme='dark'] selector.
| token | purpose |
|---|---|
--fm-surface-bg / -fg / -border / -radius / -shadow |
menu container |
--fm-z-dimmer / --fm-z-menu |
z-index stack |
--fm-body-padding-x / -y |
inset between body and menu border |
--fm-row-padding-x / -y / -gap / -radius / -min-h / -big-min-h |
row geometry |
--fm-section-padding-x / -top / -bot |
section header |
--fm-chrome-padding-x / -y |
header / footer / filter |
--fm-font-size-row / -caption / -section |
typography |
--fm-row-hover-bg / -active-bg / -active-fg / -disabled-opacity |
row states |
--fm-divider-color |
separators |
--fm-accent / -fg / --fm-destructive / --fm-muted-fg |
accents |
Built-in component classes consumers can target for finer control:
.fm-menu, .fm-dimmer, .fm-body, .fm-chrome, .fm-list, .fm-row,
.fm-row__name, .fm-row__icon, .fm-row__caption, .fm-row__suffix,
.fm-section, .fm-divider, .fm-switch, .fm-switch__thumb, .fm-swatch.
Variants via attribute selectors: .fm-row[data-active='true'],
.fm-switch[data-on='true'], .fm-dimmer--passthrough,
.fm-dimmer--visible.
Hosted at https://ra3orblade.github.io/fancy-menus/ (deployed from main by
.github/workflows/pages.yml). Link to a specific example with
#<exampleId>, e.g. #datePicker. To run it locally:
bun install
cd examples/playground
bun run devSidebar | Editor | Preview layout. Picking an example opens its menu from the Open menu button in the preview; that button re-opens it after Esc / click-outside. Each example exposes its config as form controls (Edit tab) and as live JSON (JSON tab); changes re-register the menu and re-open it so you can see the effect immediately.
Smoke and interaction tests:
cd examples/playground
bun scripts/smoke.mjs
bun scripts/interact.mjsREADME screenshots are generated, not hand-captured. With the dev server
running, bun scripts/screenshots.mjs rewrites docs/screenshots/*.png.
- npm publish prep for
@react-fancy-menus/core(version bump,files,publishConfig, export map verification). - Heavier panel renderers —
codeEditor,katexPreview,qrCode— ship lightweight fallbacks; consumers who need monaco / KaTeX / a scannable QR can swap to akind: Custompanel.
All row variants (chip, selectNav, filterRule, checkbox) and panel
kinds are wired into the runtime. Icons use @phosphor-icons/react; the
schema's IconParam is a component (or { icon, size, color, weight }
spec) — string names are not accepted.
See CLAUDE.md for repo conventions.
MIT






