Headless, typesafe, router-agnostic navigation menu — you own every element and style; it owns the tree, the disclosure state, and the active branch. A data model (defineMenu), a React renderer (<Menu>), and a useMenu hook.
<Menu> renders none of its own markup: it drives recursion and state, you supply the renderItem component and the shell. It gives you a11y primitives (a stable id for aria-controls/aria-expanded), but keyboard/focus handling is yours.
pnpm add @dennation/menuimport { defineMenu } from "@dennation/menu";
const menu = defineMenu({
"/": { title: "Dashboard" },
"/projects": { title: "Projects" },
"/projects/active": { title: "Active", parent: "/projects" },
"/projects/archived": { title: "Archived", parent: "/projects" },
"/settings": { title: "Settings" },
"/settings/profile": { title: "Profile", parent: "/settings" },
"/settings/billing": { title: "Billing", parent: "/settings" },
"https://docs.example.com": { title: "Docs" }, // external URL as its own href
});Render it with your own Item and wrap the items in your own shell via the
render-prop children — <Menu> owns the open/closed state, the active item,
and recursion; the Item renders the link. Item props are
discriminated on collapsible, so open/toggle exist only on a section;
isActive is on every item:
import { Menu, type MenuItemProps, type MenuProps } from "@dennation/menu/react";
function Item(props: MenuItemProps) {
const { item, level, isActive } = props;
const indent = level > 0 ? "pl-4" : "";
const link = item.href ? (
<a
href={item.href}
aria-current={isActive ? "page" : undefined}
className="block rounded px-3 py-2 hover:bg-gray-100 aria-[current]:font-semibold aria-[current]:text-blue-600"
>
{item.title}
</a>
) : (
<span className="px-3 py-2 text-xs font-medium uppercase text-gray-500">
{item.title}
</span>
);
// A collapsible section: the link plus a toggle, then its children.
if (props.collapsible) {
return (
<li className={indent}>
<div className="flex items-center">
{link}
<button
type="button"
onClick={props.toggle}
aria-expanded={props.open}
className="px-2 text-gray-400"
>
{props.open ? "−" : "+"}
</button>
</div>
{props.children}
</li>
);
}
// A leaf link or an always-open group header — no toggle.
return (
<li className={indent}>
{link}
{props.children}
</li>
);
}Bind your Item and shell once in an AppMenu, then pass only data props at
each call site:
function AppMenu(props: Omit<MenuProps, "renderItem" | "children">) {
return (
<Menu {...props} renderItem={Item}>
{(items) => <nav aria-label="Main"><ul>{items}</ul></nav>}
</Menu>
);
}The shell is fully yours — put a search box, a header, or a footer around
{items} however you need. <Menu> renders only the entries.
Finally, make it aware of the current page with useMenu: it highlights the
active item, expands its branch, and gives you the breadcrumb trail. You tell
it which item is active (resolve the id however your router works); it holds the
open/closed state and hands <Menu> a store via menuProps:
import { findMenuItemBy } from "@dennation/menu";
import { useMenu } from "@dennation/menu/react";
function Sidebar() {
const { pathname } = useLocation();
const { menuProps, trail, setActive } = useMenu(menu);
// Resolve the active item however you like (exact, prefix, router match) and
// pass its id — it need not equal the path.
useEffect(() => {
const active = findMenuItemBy(menu, (i) => i.href === pathname);
setActive(active?.id);
}, [pathname]);
return (
<>
<Breadcrumbs trail={trail} />
<AppMenu {...menuProps} />
</>
);
}That's a complete router-aware sidebar. useMenu is optional — <AppMenu menu={menu} />
on its own still renders and toggles sections, it just won't track the active
route. Because the state lives in a store, toggling one section re-renders only
that node, never the whole Sidebar.
defineMenu(input)— resolves a keyedMenuInputinto a nestedMenu. The input is an object keyed by identity (the entry'shrefby default, or an arbitrary id whenhref: false); hierarchy comes fromparent, not nesting. Siblings sort byorder, then insertion order. Items with an unknownparentare hoisted to the top level with a dev warning; aparentcycle warns too. Each outputMenuItemcarries its input key as a stableid— use it for React keys,aria-controls, or matching a node back to your data.getMenuTrail(menu, id)/findMenuItem(menu, id)— the ancestor chain / the item for an exactid.…By(menu, predicate)variants match by any condition (first pre-order match) — resolve an active item by prefix/router-match/role off React and feed itsidtouseMenu.
The renderer. Props:
| Prop | Type | |
|---|---|---|
menu |
Menu<M> |
The tree from defineMenu. |
renderItem |
ComponentType<MenuItemProps<M>> |
Component each entry renders through. |
children |
(items: ReactNode) => ReactNode |
Wraps the items in your shell. |
renderBeforeItem? / renderAfterItem? |
ComponentType<MenuSlotProps<M>> |
Component rendered around each item (divider, group heading). |
store? |
MenuStateStore |
State store from useMenu. Omit → <Menu> owns state. |
defaultOpen? |
MenuOpenState |
Initial disclosure when <Menu> owns it. |
onOpenChange? |
(next: MenuOpenState) => void |
Notified on every toggle (persistence). |
Owns the active item and disclosure state.
Options
| Option | Type | |
|---|---|---|
defaultActiveId? |
string |
Initial active item; its branch is expanded. |
defaultOpen? |
MenuOpenState |
Initial disclosure state. |
Returns
| Field | Type | |
|---|---|---|
menuProps |
{ menu, store } |
Spread into <Menu>. |
activeId |
string | undefined |
Current active id (readonly). |
activeItem |
MenuItem<M> | undefined |
Current active item (readonly). |
setActive |
(id: string | undefined) => void |
Set active by id; expands its branch. |
trail |
MenuItem<M>[] |
Root → active item (breadcrumbs). |
isOpen |
(id: string) => boolean |
Whether a section is open. |
open / close / toggle |
(id: string) => void |
Drive one section. |
subscribeOpenState |
(listener) => () => void |
Subscribe to changes (no re-render). |
Menu item — an entry: MenuItemInput on the way into defineMenu, MenuItem on the way out.
| Field | Type | Where | |
|---|---|---|---|
title |
string |
both | Display text. |
href |
string | false (in) / string (out) |
both | Link target; false on input → non-navigable container. Defaults to the entry key. |
id |
string |
out | Stable identity — the input key. |
parent |
string |
in | Parent entry's key → nesting. |
order |
number |
in | Sort hint among siblings (lower first). |
items |
MenuItem<M>[] |
out | Resolved children. |
defaultOpen |
boolean |
both | Start a section expanded (default true). |
collapsible |
boolean |
both | false → always-open group header. |
meta |
M |
both | Opaque per-item data (see below). |
MenuItemProps<M> — what your renderItem receives, discriminated on collapsible:
| Field | Type | |
|---|---|---|
item |
MenuItem<M> |
The entry to render. |
level |
number |
Nesting depth (0 at the top). |
isActive |
boolean |
Whether this is the active item (from useMenu's setActive). |
containsActive |
boolean |
Whether the active item is anywhere in this item's subtree — highlight a section whose child is active. |
collapsible |
boolean |
Discriminant. |
open / toggle |
boolean / () => void |
Present only when collapsible: true. |
children |
ReactNode |
The rendered nested level (required on a section, optional on a leaf). |
MenuItemPropsOf<typeof menu> — the same props with M inferred from a menu, so a standalone Item doesn't repeat the meta type. See Menu with icons.
MenuSlotProps<M> — what a renderBeforeItem/renderAfterItem slot receives: the item plus its state, without toggle/children (a slot renders next to the item, it doesn't control or contain it):
| Field | Type | |
|---|---|---|
item |
MenuItem<M> |
The item this slot is rendered next to. |
level |
number |
Nesting depth (0 at the top). |
open |
boolean |
Whether the item's section is expanded. |
isActive |
boolean |
Whether the item is the active item. |
containsActive |
boolean |
Whether the active item is in the item's subtree. |
Attach your own data to items (badges, flags, counters) via meta. defineMenu
infers its type M from the input's meta fields — the library carries it
verbatim and never reads it, and it's that type everywhere (input, output, Item):
const menu = defineMenu({ "/inbox": { title: "Inbox", meta: { count: 3 } } });
menu[0].meta.count; // numberAn adapter turns a framework's route/config shape into a MenuInput you spread into defineMenu. It's only a source — defineMenu stays the single place that assembles the tree, so you can mix adapters with manual entries in one spread.
import { defineMenu } from "@dennation/menu";
import { menuInputFromRouteTree } from "@dennation/menu/adapters/tanstack-router";
import { routeTree } from "./routeTree.gen";
const menu = defineMenu(menuInputFromRouteTree(routeTree, { omit: ["/login"] }));Because it returns plain MenuInput, you can mix it with manual entries in the
same defineMenu spread — see Custom entries in a generated menu.
menuInputFromRouteTree(routeTree, options?) walks the route tree into a MenuInput keyed by fullPath. Pathless and layout routes are transparent; omit drops a route with its subtree (typed against the tree). How a route describes itself is read from staticData.menu — override the source with getRouteMenu:
createFileRoute("/button")({
component: ButtonPage,
staticData: { menu: { title: "Button", order: 2, meta: { badge: "new" } } },
});title falls back to a title-cased last path segment; order/meta come from staticData.menu.
Register menu on staticData once, so it's typed at every route (including
meta under your own type):
import type { RouteMenuEntry } from "@dennation/menu/adapters/tanstack-router";
declare module "@tanstack/router-core" {
interface StaticDataRouteOption {
menu?: RouteMenuEntry<Meta>;
}
}That register is the only place you name Meta: it flows through
menuInputFromRouteTree into defineMenu, so defineMenu(menuInputFromRouteTree(routeTree))
is typed Menu<Meta> with no explicit type argument.
Recipes for the cases that don't fit the happy path.
An entry's href can be any URL — it's just the key. Branch in your Item on
the shape you care about:
{item.href?.startsWith("http") ? (
<a href={item.href} target="_blank" rel="noreferrer">{item.title}</a>
) : (
<Link to={item.href}>{item.title}</Link>
)}Give a container href: false and it becomes an id-only entry with no link;
children point their parent at that id. Add collapsible: false for a header
that stays open:
defineMenu({
team: { title: "Team", href: false, collapsible: false },
"/members": { title: "Members", parent: "team" },
"/roles": { title: "Roles", parent: "team" },
});subscribeOpenState fires on every change without re-rendering; seed the next
mount from defaultOpen:
const saved = JSON.parse(localStorage.getItem("menu-open") ?? "{}");
const { menuProps, subscribeOpenState } = useMenu(menu, { defaultOpen: saved });
useEffect(
() =>
subscribeOpenState((open) =>
localStorage.setItem("menu-open", JSON.stringify(open)),
),
[subscribeOpenState],
);The render-prop children gives you full control of the wrapper — put a search
box, header, or footer around {items}. Its state is yours; keep the shell a
stable component so that state survives re-renders:
<Menu menu={menu} renderItem={Item}>
{(items) => (
<nav>
<SearchBox />
<ul>{items}</ul>
</nav>
)}
</Menu>If the search filters the menu, filter the menu data outside <Menu> and pass
the result — disclosure state is keyed by id, so filtering never loses open
sections.
menuInputFromRouteTree returns plain MenuInput, so spread it and add,
override, or re-parent entries in the same object — parent is type-checked
against the route paths:
const menu = defineMenu({
...menuInputFromRouteTree(routeTree),
"/changelog": { title: "Changelog", parent: "/settings" }, // entry with no route
"/settings": { title: "Settings", order: 0 }, // override generated
"https://docs.example.com": { title: "Docs" }, // external link
});The model has no icon field — it stays free of React. Put the icon in meta
(which the library carries but never reads) and render it in your Item:
import type { MenuItemPropsOf } from "@dennation/menu/react";
const menu = defineMenu({
"/": { title: "Dashboard", meta: { icon: <Home /> } },
"/settings": { title: "Settings", meta: { icon: <Cog /> } },
});
// `MenuItemPropsOf<typeof menu>` infers the meta type from `menu` — no repeat.
function Item(props: MenuItemPropsOf<typeof menu>) {
return (
<a href={props.item.href} className="flex items-center gap-2">
{props.item.meta?.icon}
{props.item.title}
</a>
);
}With the TanStack adapter, icons ride along in staticData.menu.meta.
MIT