Page Layouts
The configurable sidebar — its four display modes, live search, favorites/pinning, count badges, the presence user card, single-open accordion, and the menu.ts single source of truth.
Overview
The sidebar (src/layout/Sidebar.tsx) is the primary in-app navigation. It reads from a single menu
definition (src/data/menu.ts) and renders in one of four display modes driven by LayoutConfig:
- Default — the standard full-width panel (16rem).
- Minified — a 5rem icon rail that hover-expands to the full panel (overlaying content, no reflow).
- Dual — a 4rem icon rail of menu sections plus a secondary panel showing the active section.
- Header-only — the sidebar is hidden entirely; navigation moves into the header.
On top of the tree it layers live search, favorites/pinning, numeric count badges, a presence
user card footer, and a single-open accordion that auto-opens the active branch at any nesting depth.
Below lg the sidebar becomes an animated mobile drawer regardless of mode.
Navigation is data-driven: menu.ts is the single source of truth for both the sidebar and the
auto-derived breadcrumbs, and any menu leaf without a matching route automatically renders a titled
placeholder (see Architecture).
Architecture & files
| File | Responsibility |
|---|---|
src/layout/Sidebar.tsx | The sidebar itself — Sidebar, SidebarContent, DualSidebar, NavBody, NavList, NavItem, SidebarSearch. |
src/data/menu.ts | menu: MenuSection[] — the recursive nav tree; MenuItem / MenuSection types. |
src/data/navBadges.ts | navBadges — route → numeric count badge (mock data). |
src/hooks/useFavorites.ts | Pinned-item store (useSyncExternalStore over localStorage('nav-favorites')). |
src/layout/SidebarUserCard.tsx | The footer user card (presence via usePresence). |
src/components/ui/Breadcrumbs.tsx | Auto-derives the breadcrumb trail from menu. |
src/pages/layouts/LayoutPreviewPage.tsx | The Page-Layout preview routes + LAYOUT_PREVIEWS. |
src/styles/_sidebar.scss | Rail sizing / minified hover-reveal / dual layout — keyed off .app-shell[data-*]. |
The mode is selected by the same data-* attributes AppLayout sets on .app-shell
(data-min-sidebar, data-dual-sidebar, data-header-only) — see
Layout & Shell.
Usage
You don't render the sidebar directly — AppLayout does. You interact with it by:
Edit the menu
Add or change a leaf in src/data/menu.ts.
Add the matching route
Add it in src/routes.tsx (or let the leaf fall back to a placeholder).
Add the label
Add the nav:<key> label in src/locales/<lng>/nav.json.
Switching modes is a config change (from the customizer, Layout Settings, or useLayout().update):
import {useLayout} from '@/context/LayoutContext'
function SidebarModeButtons() {
const {update} = useLayout()
return (
<>
{/* enabling one mode clears the others (mutually exclusive) */}
<button onClick={() => update({minSidebar: true, dualSidebar: false, headerOnly: false})}>Minified</button>
<button onClick={() => update({dualSidebar: true, minSidebar: false, headerOnly: false})}>Dual</button>
<button onClick={() => update({headerOnly: true, minSidebar: false, dualSidebar: false})}>Header only</button>
</>
)
}MenuItem (src/data/menu.ts)
| Field | Type | Description |
|---|---|---|
key | string | i18n key in the nav namespace; also the stable React key. |
label | string | English label — fallback + reference. |
to | string (optional) | Route path. A leaf (has to, no children) is a navigable link. |
icon | LucideIcon (optional) | Item icon. Deep leaves without one inherit their group/section icon in the rail. |
badgeKey | string (optional) | i18n key (in nav) for a static text badge (e.g. "New"). |
action | Partial<LayoutConfig> (optional) | Applies a config patch on click instead of navigating. |
children | MenuItem[] (optional) | Recursive nested children (rendered at any depth). |
MenuSection (src/data/menu.ts)
| Field | Type | Description |
|---|---|---|
key | string | i18n key for the section heading. |
heading | string | English heading; also the dual-rail tooltip + secondary header. |
icon | LucideIcon | Section icon — shown in the dual-sidebar rail. |
items | MenuItem[] | Top-level groups/leaves in the section. |
useFavorites() (src/hooks/useFavorites.ts)
| Member | Type | Description |
|---|---|---|
favorites | string[] | Pinned route paths (persisted to localStorage('nav-favorites')). |
isFavorite | (path: string) => boolean | Whether a path is pinned. |
toggleFavorite | (path: string) => void | Pin/unpin a path (also exported standalone). |
navBadges (src/data/navBadges.ts)
A Record<string, number> keyed by route path. The rendered count animates via AnimatedNumber. Seeded
mock values include /apps/contacts: 4 (pending applications). In a real app these would come from the API.
The four sidebar modes
| Mode | Config | Behavior |
|---|---|---|
| Default | all off | Full 16rem panel: search, favorites, sectioned tree, user card. |
| Minified | minSidebar | 5rem icon rail. Labels/badges/search/headings collapse; hover-expands to full width, overlaying content (no reflow). Expanded submenus (nav ul ul) hide while collapsed. |
| Dual | dualSidebar | 4rem rail of section icons (with Tooltip, no hover-expand) + a secondary panel for the active section. Favorites is its own first rail menu. Desktop-only. |
| Header-only | headerOnly | data-header-only sets --sidebar-width: 0; nav moves into the header. |
The three are mutually exclusive — enabling one clears the others.
Search / filter
SidebarSearch renders a live filter box. Typing filters the whole flattened leaf set (allLeaves) and
highlights the matched substring via a <mark>; an empty result shows a nav:noResults message. The box
carries the nav-label/nav-search classes so it fully collapses in the minified rail and reappears on
hover-expand.
Favorites / pinning
Each leaf shows a hover-star (visible on hover or when pinned). Clicking it toggles the path in
useFavorites (a useSyncExternalStore store persisted to localStorage('nav-favorites')), and every star
- the synthesized "Favorites" section update live.
- First-time seed:
DEFAULT_FAVORITES = ['/layout-settings']so the section isn't empty on first load. Users who already have a saved list (even an empty one) keep their own selection. - Favorites render in a synthesized "Favorites" section (its own first rail menu in dual mode).
- Deep-leaf favorites have no own icon, so they inherit their group's icon (
favWithIcon/iconByPath) to still show an icon in the minified rail. - The empty state (dual mode) is an SVG illustration +
nav:noFavorites/nav:noFavoritesHint.
nav-favorites is registered in src/lib/appStorage.ts so "Reset to defaults" clears it.
Numeric count badges
navBadges (src/data/navBadges.ts) maps a route path to a count. When a leaf's to has an entry, a
neutral Badge with an AnimatedNumber renders. On hover (or when the item is favorited) the badge fades
out so the pin-star can take its slot. A separate static badgeKey renders a primary text badge alongside.
User card footer
SidebarUserCard (src/layout/SidebarUserCard.tsx) pins to the bottom of the panel (and the dual
secondary), showing the current user and a live presence dot from the shared usePresence store — so it
stays in sync with the profile menu.
Accordion + auto-open
When config.accordionMenu is on, the NavList controller makes each section's groups single-open
(opening one closes the rest) at every nesting depth — a group renders its children through NavList,
so nested subgroups (e.g. Page Layouts) are single-open too.
- Default sidebar: only the active group is expanded; others collapse as you navigate.
- Dual secondary: passes
openFirstso a lone panel is never empty (opens active-or-first group), and expands ALL groups when the section is short (countItems(section.items) <= 16) so short panels need no scroll; longer ones use the accordion. - The active route's parent auto-opens and the active leaf scrolls to center (after the accordion
finishes expanding). Nested indent is
pl-5, compounding per depth.
The menu source & sections
menu.ts ships 5 sections — Dashboard / Components / Data / Pages / Settings. This keeps the dual rail
rich and the panels balanced. MenuItem.action (a Partial<LayoutConfig>) is available for
apply-config-without-navigating shortcuts (no current items use it; the Page-Layout previews route to real
pages instead).
Menu ↔ route contract
Any menu leaf whose to has no matching appRoutes entry auto-renders a titled <Placeholder> — so
the menu and router can never drift. To add a real page: (1) add the appRoutes entry in src/routes.tsx,
(2) add the nav item in src/data/menu.ts, (3) add its nav:<key> label. See
Architecture.
Page-Layout preview routes
The pageLayouts group nests 5 subgroups — Sidebar Modes / Sidebar Theme / Navigation / Widgets /
Feedback & Motion — each a real route. A single shared template LayoutPreviewPage({variant}) is driven by
the LAYOUT_PREVIEWS data array in src/pages/layouts/LayoutPreviewPage.tsx (routes.tsx folds it into
appRoutes). Each preview:
- Applies its
configpatch against the captured baseline on mount ({...baseline, ...preview.config}), so it shows a clean state with no leftover flags from the previously viewed preview. - Reverts via a module-level
previewSession+usePreviewSessionGuard()(called in the stableAppLayout) when you navigate out of/page-layouts/*. - Fires a single info toast on entry (guarded by the session, so hopping between previews never stacks).
livepreviews render interactive pills/toggles (e.g.toastPosition,pageTransition,showPrimaryNav) that apply live; sidebar-theme previews forcethemeMode: 'light'so the dark/OLED sidebar is visible.
The content is a polished Skeleton dashboard with a ScrambleText "demo content" caption — not a loading
state. See Layout & Shell for the underlying options and
Motion & Effects for the transition/loader kinds.
Examples
Adding a nav leaf:
// src/data/menu.ts — inside a section's `items` (or a group's `children`)
{key: 'reports', label: 'Reports', to: '/reports', icon: FileBarChart}// src/locales/en/nav.json
{"reports": "Reports"}Then add the route in src/routes.tsx (or let it fall back to a placeholder).
A nested (recursive) group:
{
key: 'settings',
label: 'Settings',
icon: Settings,
children: [
{key: 'siteSettings', label: 'Site Settings', to: '/settings'},
{key: 'permissions', label: 'Permissions', to: '/settings/permissions'},
],
}Adding a live count badge:
// src/data/navBadges.ts
export const navBadges: Record<string, number> = {
'/apps/contacts': 4, // pending applications
'/reports': 9,
}Toggling a favorite programmatically:
import {toggleFavorite, useFavorites} from '@/hooks/useFavorites'
function PinButton({path}: {path: string}) {
const {isFavorite} = useFavorites()
return (
<button onClick={() => toggleFavorite(path)}>
{isFavorite(path) ? 'Unpin' : 'Pin'}
</button>
)
}Best practices
menu.tsis the single source of truth — edit navigation there, never hard-code links elsewhere. Breadcrumbs and placeholders both derive from it.- Always store keys, not literal strings, on menu items (
item.key,badgeKey) — labels resolve viat('nav:<key>')at render. - Keep the three-step contract in sync (menu item · route · i18n label) so the menu and router never drift and no accidental placeholder appears.
- Multi-line sidebar text needs
whitespace-normal— sidebar text inheritswhite-space: nowrapto keep labels one-line, so any wrapping text (e.g. the favorites hint) must opt back in. - Register new persisted nav keys (like
nav-favorites) insrc/lib/appStorage.tsso Reset clears them. - Give favorites-eligible deep leaves a discoverable group icon — leaves without their own icon inherit the group's, which is what shows in the minified rail.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| A nav item shows a placeholder page | No appRoutes entry for its to. Add one in src/routes.tsx (menu leaves without a route auto-render a placeholder). |
Nav label shows the raw key (e.g. nav:reports) | Missing nav:<key> entry in src/locales/<lng>/nav.json. |
| Favorites section is empty for everyone | Expected only if a user cleared their pins; new users are seeded ['/layout-settings']. A saved empty list is respected. |
| Minified rail leaves blank gap rows | Expanded accordion children (nav ul ul) are hidden while collapsed — they return on hover-expand. If not, check the data-min-sidebar SCSS branch. |
| Favorites hint text doesn't wrap | Add whitespace-normal — sidebar text is white-space: nowrap by default. |
| Two accordion groups open at once | accordionMenu is off, or the dual secondary is in expandAll mode (short section ≤ 16 rows). |
| Dual sidebar not visible | Dual is desktop-only; on mobile the drawer is used. Also mutually exclusive with minSidebar/headerOnly. |
FAQ
How do I add a whole new section? Append a MenuSection to menu in src/data/menu.ts with a key,
heading, icon, and items, then add the nav:<key> heading label. It appears in the standard tree and
as a rail icon in dual mode.
Can a menu item change the layout instead of navigating? Yes — set action: Partial<LayoutConfig> (no
to). Clicking applies the config patch. The Page-Layout previews use real routes instead, but the
mechanism is available.
Where do the count badges come from? src/data/navBadges.ts (mock data keyed by route). Swap it for
live API data in a real deployment.
How does auto-open decide what to expand? The NavList controller finds the group containing the active
route and opens it (single-open when accordionMenu). The active leaf then scrolls to center.
Why is the sidebar dark by default? darkSidebar defaults to true — see
Layout & Shell. It re-scopes .dark tokens to the sidebar on a light page;
oledSidebar upgrades that to pure black.
Notes for designers & content editors
- All nav labels, section headings, and badge text are i18n keys — edit them in
src/locales/<lng>/nav.json, not inmenu.ts. - Icons come from lucide-react; pick an icon name and import it in
menu.ts. - The Page Layouts preview routes let you experience every sidebar mode and layout option full-page, with realistic skeleton content, and revert automatically when you leave.
- Favorites, search, badges, and the user card work in all modes; the minified rail collapses text to icons and reveals the full panel on hover.
Related
Was this page helpful?
