PVR Tech Studio
Page layouts

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.

10 min read
Updated July 15, 2026

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

FileResponsibility
src/layout/Sidebar.tsxThe sidebar itself — Sidebar, SidebarContent, DualSidebar, NavBody, NavList, NavItem, SidebarSearch.
src/data/menu.tsmenu: MenuSection[] — the recursive nav tree; MenuItem / MenuSection types.
src/data/navBadges.tsnavBadges — route → numeric count badge (mock data).
src/hooks/useFavorites.tsPinned-item store (useSyncExternalStore over localStorage('nav-favorites')).
src/layout/SidebarUserCard.tsxThe footer user card (presence via usePresence).
src/components/ui/Breadcrumbs.tsxAuto-derives the breadcrumb trail from menu.
src/pages/layouts/LayoutPreviewPage.tsxThe Page-Layout preview routes + LAYOUT_PREVIEWS.
src/styles/_sidebar.scssRail 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>
        </>
    )
}
FieldTypeDescription
keystringi18n key in the nav namespace; also the stable React key.
labelstringEnglish label — fallback + reference.
tostring (optional)Route path. A leaf (has to, no children) is a navigable link.
iconLucideIcon (optional)Item icon. Deep leaves without one inherit their group/section icon in the rail.
badgeKeystring (optional)i18n key (in nav) for a static text badge (e.g. "New").
actionPartial<LayoutConfig> (optional)Applies a config patch on click instead of navigating.
childrenMenuItem[] (optional)Recursive nested children (rendered at any depth).
FieldTypeDescription
keystringi18n key for the section heading.
headingstringEnglish heading; also the dual-rail tooltip + secondary header.
iconLucideIconSection icon — shown in the dual-sidebar rail.
itemsMenuItem[]Top-level groups/leaves in the section.

useFavorites() (src/hooks/useFavorites.ts)

MemberTypeDescription
favoritesstring[]Pinned route paths (persisted to localStorage('nav-favorites')).
isFavorite(path: string) => booleanWhether a path is pinned.
toggleFavorite(path: string) => voidPin/unpin a path (also exported standalone).

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

ModeConfigBehavior
Defaultall offFull 16rem panel: search, favorites, sectioned tree, user card.
MinifiedminSidebar5rem icon rail. Labels/badges/search/headings collapse; hover-expands to full width, overlaying content (no reflow). Expanded submenus (nav ul ul) hide while collapsed.
DualdualSidebar4rem 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-onlyheaderOnlydata-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.

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 openFirst so 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).

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 config patch 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 stable AppLayout) when you navigate out of /page-layouts/*.
  • Fires a single info toast on entry (guarded by the session, so hopping between previews never stacks).
  • live previews render interactive pills/toggles (e.g. toastPosition, pageTransition, showPrimaryNav) that apply live; sidebar-theme previews force themeMode: '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.ts is 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 via t('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 inherits white-space: nowrap to 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) in src/lib/appStorage.ts so 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

SymptomCause / fix
A nav item shows a placeholder pageNo 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 everyoneExpected only if a user cleared their pins; new users are seeded ['/layout-settings']. A saved empty list is respected.
Minified rail leaves blank gap rowsExpanded 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 wrapAdd whitespace-normal — sidebar text is white-space: nowrap by default.
Two accordion groups open at onceaccordionMenu is off, or the dual secondary is in expandAll mode (short section ≤ 16 rows).
Dual sidebar not visibleDual 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 in menu.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.

Was this page helpful?