Skip to content

ethanhann/mantine-nav

Repository files navigation

@ethanhann/mantine-nav

npm version CI License: MIT Coverage Storybook

A React navigation component library built on Mantine v9.

Provides a responsive app shell, sidebar, header, and nav-tree components with multi-level nesting, keyboard navigation, and SaaS-oriented building blocks (workspace switcher, user menu, plan badge, notification indicator).

Installation

npm install @ethanhann/mantine-nav

Peer dependencies: React 19+, @mantine/core 9+, @mantine/hooks 9+, @mantine/spotlight 9+, @tabler/icons-react 3+.

Quick Start

Compose NavShell, NavHeader, NavSidebar, and NavGroup to get a responsive layout with a collapsible sidebar and mobile drawer:

import {NavShell, NavHeader, NavSidebar, NavGroup} from '@ethanhann/mantine-nav';
import type {NavItemType} from '@ethanhann/mantine-nav';

const items: NavItemType[] = [
    {id: 'home', type: 'link', label: 'Home', href: '/', icon: <HomeIcon/>},
    {
        id: 'products',
        type: 'group',
        label: 'Products',
        icon: <BoxIcon/>,
        children: [
            {id: 'catalog', type: 'link', label: 'Catalog', href: '/products'},
            {id: 'inventory', type: 'link', label: 'Inventory', href: '/products/inventory'},
        ],
    },
    {id: 'div-1', type: 'divider'},
    {id: 'settings', type: 'link', label: 'Settings', href: '/settings'},
];

function App() {
    return (
        <NavShell
            mainProps={{
                // Optionally override AppShell.main props.
            }}
            header={<NavHeader logo={<Logo/>}/>}
            sidebar={
                <NavSidebar>
                    <NavGroup items={items} currentPath={location.pathname}/>
                </NavSidebar>
            }
        >
            {/* page content */}
        </NavShell>
    );
}

NavShell wraps Mantine's AppShell and manages responsive collapse (desktop) and drawer toggling (mobile). Any descendant can read state via useNavShell() or useOptionalNavShell().

Layout is configured with headerHeight, sidebarWidth, sidebarCollapsedWidth, sidebarBreakpoint, asideWidth, asideBreakpoint, footerHeight, padding, and withBorder. For layouts without a header, render the exported NavBurger anywhere inside the shell to toggle the mobile drawer. It renders nothing outside a NavShell.

Nav Items

NavItemType is a discriminated union with four variants:

// Clickable link
{
    id, type
:
    'link', label, href, icon ?, badge ?, disabled ?, external ?, onClick ?, activeMatch ?
}

// Collapsible group containing children
{
    id, type
:
    'group', label, icon ?, badge ?, children
:
    NavItemType[], defaultOpened ?, disabled ?
}

// Non-interactive section header
{
    id, type
:
    'section', label
}

// Horizontal divider with an optional inline label
{
    id, type
:
    'divider', label ?
}

All items support visible?: boolean | (() => boolean) to hide per role/flag, and weight?: number to control sort order. Links and groups also accept an "aria-label" used as the accessible name of the rendered tree item.

onClick and navigation

A link's onClick runs for side effects (analytics, telemetry) and does not suppress navigation when the item has a real href — the handler fires first, then the browser (or your linkComponent) navigates. Call e.preventDefault() yourself if you need to stop it:

// Track, then navigate normally:
{ id: 'pricing', type: 'link', label: 'Pricing', href: '/pricing',
  onClick: () => track('nav_click', { to: '/pricing' }) }

// Action-only item — prevent navigation and open a modal:
{ id: 'feedback', type: 'link', label: 'Send Feedback', href: '#',
  onClick: (e) => { e.preventDefault(); openFeedbackModal(); } }

Custom item rendering

Pass renderItem={(item, depth) => ReactNode} to NavGroup to fully control how items are drawn. The library wraps your output so each item keeps its accessibility plumbing — role="treeitem", data-item-id, aria-current / aria-expanded, roving focus for keyboard navigation, and click routing through onItemClick. You supply only the visuals:

<NavGroup
    items={items}
    onItemClick={(item) => router.push(item.href)}
    renderItem={(item) =>
        item.type === 'link' ? (
            <Group justify="space-between" px="sm" py={6}>
                <Group gap="xs">{item.icon}<Text size="sm">{item.label}</Text></Group>
                {item.data?.count ? <Badge circle>{item.data.count}</Badge> : null}
            </Group>
        ) : null
    }
/>

Example: Marketing CRM

import {NavShell, NavHeader, NavSidebar, NavGroup} from '@ethanhann/mantine-nav';
import type {NavItemType} from '@ethanhann/mantine-nav';
import {
    IconHome, IconUsers, IconMail, IconTarget,
    IconChartBar, IconSettings, IconCalendar, IconFileText,
} from '@tabler/icons-react';
import {Badge} from '@mantine/core';

const crmItems: NavItemType[] = [
    {id: 'dashboard', type: 'link', label: 'Dashboard', href: '/', icon: <IconHome size={18}/>},

    {id: 'section-engage', type: 'section', label: 'Engage'},

    {
        id: 'contacts',
        type: 'group',
        label: 'Contacts',
        icon: <IconUsers size={18}/>,
        defaultOpened: true,
        children: [
            {id: 'all-contacts', type: 'link', label: 'All Contacts', href: '/contacts'},
            {id: 'segments', type: 'link', label: 'Segments', href: '/contacts/segments'},
            {id: 'lists', type: 'link', label: 'Lists', href: '/contacts/lists'},
        ],
    },

    {
        id: 'campaigns',
        type: 'group',
        label: 'Campaigns',
        icon: <IconMail size={18}/>,
        badge: <Badge size="xs" color="green">2 active</Badge>,
        children: [
            {id: 'email', type: 'link', label: 'Email', href: '/campaigns/email'},
            {id: 'sms', type: 'link', label: 'SMS', href: '/campaigns/sms'},
            {id: 'social', type: 'link', label: 'Social', href: '/campaigns/social'},
        ],
    },

    {id: 'automations', type: 'link', label: 'Automations', href: '/automations', icon: <IconTarget size={18}/>},
    {id: 'calendar', type: 'link', label: 'Calendar', href: '/calendar', icon: <IconCalendar size={18}/>},

    {id: 'div-1', type: 'divider'},
    {id: 'section-analyze', type: 'section', label: 'Analyze'},

    {id: 'reports', type: 'link', label: 'Reports', href: '/reports', icon: <IconChartBar size={18}/>},
    {id: 'templates', type: 'link', label: 'Templates', href: '/templates', icon: <IconFileText size={18}/>},

    {id: 'div-2', type: 'divider'},

    {
        id: 'settings',
        type: 'link',
        label: 'Settings',
        href: '/settings',
        icon: <IconSettings size={18}/>,
        disabled: true
    },
];

function MarketingCRM() {
    return (
        <NavShell
            header={<NavHeader logo={<Logo/>}/>}
            sidebar={
                <NavSidebar>
                    <NavGroup items={crmItems} currentPath={location.pathname} activeMatcher="prefix" accordion/>
                </NavSidebar>
            }
        >
            {/* page content */}
        </NavShell>
    );
}

This demonstrates:

  • Section headers to visually group related items ("Engage", "Analyze")
  • Collapsible groups with nested links (Contacts, Campaigns)
  • Badges on groups to surface live status ("2 active")
  • Dividers to separate logical sections
  • Prefix matching so /contacts/segments highlights the Contacts group and the Segments link
  • Accordion mode so only one group is open at a time
  • Disabled items for features not yet available

Active Matching

Pass activeMatcher to NavGroup to control how the current path maps to items:

<NavGroup items={items} currentPath="/products/inventory" activeMatcher="prefix"/>
Strategy Behavior
'exact' href must equal currentPath
'prefix' (default) currentPath must start with href
'regex' href is treated as a regex pattern
RegExp Match currentPath against the provided regex
(currentPath, itemHref) => boolean Custom matcher

Individual items can override the strategy via activeMatch.

Subscribe to active-link changes with onActiveChange:

<NavGroup items={items} currentPath={pathname} onActiveChange={(item) => setBreadcrumbSource(item)}/>

The callback fires with the resolved active link whenever it changes, and with null when nothing matches.

NavHeader

<NavHeader
    logo={<Logo/>}
    environment={{label: 'Staging', color: 'orange'}}
    rightSection={
        <Group gap="xs">
            <NotificationIndicator count={3}/>
            <ColorModePicker/>
            <UserMenu user={user} menuItems={menuItems}/>
        </Group>
    }
>
    {/* Optional center content (breadcrumbs, search, etc.) */}
</NavHeader>

NavSidebar

NavSidebar provides header/body/footer slots. Header and footer hide automatically when the sidebar is collapsed on desktop:

<NavSidebar
    header={<WorkspaceSwitcher workspaces={workspaces} activeWorkspace={current} onSwitch={setWorkspace}/>}
    footer={<UserMenu user={user} menuItems={menuItems}/>}
    collapseTogglePosition="footer"
>
    <NavGroup items={items} currentPath={location.pathname}/>
</NavSidebar>

Header and footer sections animate open and closed with Mantine Collapse and are never height-clamped while expanded. The sectionMaxHeight prop from earlier versions is deprecated and ignored. NavSidebar also renders outside a NavShell, in which case the collapse toggle is omitted.

Router Integration

Pass a router-aware linkComponent (and optionally hrefProp) to NavShell — all link items in descendant NavGroups will use it. Items with external: true bypass it and render as <a target="_blank" rel="noopener noreferrer">.

// Next.js
import Link from 'next/link';

<NavShell linkComponent={Link} /* hrefProp defaults to "href" */>...</NavShell>

// React Router
import {Link} from 'react-router-dom';

<NavShell linkComponent={Link} hrefProp="to">...</NavShell>

Controlled State

Layout state is uncontrolled by default, and every stateful surface also accepts a controlled prop pair:

// Sidebar collapse
<NavShell desktopCollapsed={collapsed} onDesktopCollapsedChange={setCollapsed} sidebar={sidebar}>...</NavShell>

// Expanded groups
<NavGroup items={items} expandedKeys={expanded} onExpandedChange={setExpanded}/>

// Dropdown menus
<UserMenu user={user} opened={menuOpened} onOpenChange={setMenuOpened}/>
<NotificationIndicator notifications={notifications} opened={opened} onOpenChange={setOpened}/>

In uncontrolled mode the change callbacks still fire with the intended value, so they double as event hooks.

Styling

NavShell, NavGroup, NavSidebar, and NavHeader accept slot-based classNames and styles props, so visuals can be themed with plain CSS instead of overriding inline styles.

Component Slots
NavShell header, navbar, aside, footer, main
NavGroup root, item, section, divider
NavSidebar header, body, footer
NavHeader root, logo, center, right
<NavGroup
    items={items}
    classNames={{item: 'app-nav-item'}}
    styles={{root: {padding: 4}}}
/>

UserMenu, NotificationIndicator, ContextSwitcher, and the ColorModePicker menu variant accept width and position for their dropdowns.

Localization

Every user-facing string can be overridden. Components with several strings take a labels object, and single-string surfaces take an aria-label prop:

<NavShell labels={{toggleNavigation: 'Menü öffnen'}} sidebar={sidebar}>...</NavShell>
<NavGroup items={items} aria-label="Hauptnavigation"/>
<NavSidebar labels={{expandSidebar: 'Ausklappen', collapseSidebar: 'Einklappen'}}>...</NavSidebar>
<WorkspaceSwitcher
    labels={{
        searchPlaceholder: 'Arbeitsbereiche suchen...',
        createWorkspace: 'Arbeitsbereich erstellen',
        switchWorkspace: (name) => `Arbeitsbereich wechseln, aktuell: ${name}`,
    }}
    {...props}
/>
<NotificationIndicator
    labels={{
        title: 'Benachrichtigungen',
        markAllAsRead: 'Alle als gelesen markieren',
        empty: 'Keine Benachrichtigungen',
        bell: (unread) => `Benachrichtigungen (${unread} ungelesen)`,
    }}
    {...props}
/>
<ContextSwitcher labels={{placeholder: 'Wählen', emptyMessage: 'Keine Treffer'}} {...props}/>
<CommandPalette labels={{pages: 'Seiten', actions: 'Aktionen'}} {...props}/>

ContextSwitcher's older per-string props (placeholder, searchPlaceholder, searchAriaLabel, emptyMessage) still work but are deprecated in favor of labels.

Keyboard Navigation and Accessibility

NavGroup renders an ARIA tree with a roving tabindex. Exactly one item sits in the tab order: the last focused item, else the active link, else the first visible item.

Key Behavior
Tab Enters or leaves the tree at the roving item
Arrow Up/Down Moves focus between visible items (wraps while loopNavigation is on)
Arrow Right Expands a group, or moves to its first child when already open
Arrow Left Collapses a group, or moves focus to the parent group
Home / End Moves to the first or last visible item
Enter / Space Activates the item: links navigate natively and fire onClick and onItemClick, groups toggle
Characters Type-ahead to the next item whose label matches the typed prefix

Disabled items are focusable but not activatable, and children of collapsed groups are skipped. The active link exposes aria-current="page" and aria-selected.

The mobile drawer moves focus to its first focusable element on open, traps Tab and Shift+Tab inside, restores focus to the previously focused element on close, and closes on Escape.

SaaS Components

import {
    WorkspaceSwitcher,
    UserMenu,
    PlanBadge,
    NotificationIndicator,
    ColorModePicker,
} from '@ethanhann/mantine-nav';

<NavSidebar
    header={
        <WorkspaceSwitcher
            workspaces={workspaces}
            activeWorkspace={currentWorkspace}
            onSwitch={switchWorkspace}
            onCreate={createWorkspace}
            searchable
        />
    }
    footer={
        <>
            <PlanBadge plan="Pro" showUpgrade onUpgrade={() => navigate('/billing')}/>
            <NotificationIndicator
                count={unreadCount}
                notifications={notifications}
                onRead={markRead}
                onReadAll={markAllRead}
            />
            <UserMenu
                user={{id: '1', name: 'Jane Doe', email: 'jane@example.com', role: 'Admin'}}
                menuItems={[
                    // `id` is optional but recommended as a stable React key when
                    // labels may repeat; it falls back to label + index otherwise.
                    {id: 'profile', label: 'Profile', onClick: () => navigate('/profile')},
                    {id: 'signout', label: 'Sign out', onClick: signOut, color: 'red', dividerBefore: true},
                ]}
            />
        </>
    }
>
    <NavGroup items={items}/>
</NavSidebar>

WorkspaceSwitcher notes:

  • onSwitch may return a promise. The dropdown then shows the built-in pending state and closes once the promise resolves.
  • Workspace.logo accepts an image URL string or any React node. The workspace name initial is the fallback.
  • renderWorkspace(workspace, isActive) customizes both the trigger and each dropdown row.
  • loading renders skeleton rows while the workspace list is being fetched.
  • placeholder sets the trigger text when the active workspace cannot be resolved from workspaces.

NotificationIndicator notes:

  • The badge count defaults to the number of unread notifications. Pass count to override it, for example with a server-side total.
  • Marking a notification read keeps the dropdown open. Notifications with an href navigate and close it.
  • loading shows skeleton rows while notifications are being fetched.

ColorModePicker props: variant ('toggle' | 'segmented' | 'menu'), custom modes ({value, label, icon, onActivate?}), a controlled value with onChange, size, and showLabels. The toggle variant cycles through modes in order. An empty modes array renders nothing.

Note: ColorSchemeToggle was removed in v0.4.0, replaced by ColorModePicker. The migration is a drop-in swap ( <ColorSchemeToggle /><ColorModePicker />); the default toggle variant cycles System → Light → Dark instead of flipping light/dark, preserving the user's system preference. To restore the old binary behavior, pass only light and dark modes.

ContextSwitcher

ContextSwitcher is a generic dropdown for switching the user's acting context — personas, tenants, environments, or anything else with a "you are acting as X" semantic. WorkspaceSwitcher is a thin preset over it.

Items carry a primary label, optional description (secondary line), icon, badge, disabled, a section for grouped lists, and a data payload that is passed back to onSelect — no lookup by id needed. active is nullable: when no context is chosen yet, the trigger renders a placeholder prompt. The active item shows a check mark and is not selectable.

Async selection is built in: when onSelect returns a promise, the clicked item shows a loader, other items are disabled, and the menu closes only once the promise resolves (it stays open on rejection, and the new item is never optimistically marked active — update active from your own state when the mutation lands).

A persona switcher for an account that holds multiple roles:

import {ContextSwitcher} from '@ethanhann/mantine-nav';

<ContextSwitcher
    items={me.personas.map((p) => ({
        id: `${p.type}:${p.id}`,
        label: p.label,                                                  // "Admin"
        description: p.organization?.name,                               // "Acme Corp"
        section: p.type === 'personal' ? 'Personal' : 'Organization roles',
        data: p,
    }))}
    active={me.actingPersona && `${me.actingPersona.type}:${me.actingPersona.id}`}
    labels={{placeholder: 'Choose a persona'}}
    onSelect={(item) =>
        // Async: the switcher shows pending state until this resolves,
        // then your refetched `me.actingPersona` drives `active`.
        switchPersona.mutateAsync({
            data: {personaType: item.data.type, personaId: item.data.id},
        })
    }
/>

Footer affordances: actions renders typed action items below a divider (e.g. { id: 'manage', label: 'Manage workspaces', onClick }), and footer accepts arbitrary content. searchable filters on label + description; maxVisible caps list height before scrolling.

Rendering escape hatches: renderItem(item, { active, pending }) replaces item content (the accessible Menu.Item wrapper is kept), and renderTarget(active, opened, { pending }) replaces the trigger entirely — return any ref-forwarding element:

<ContextSwitcher
    items={items}
    active={activeId}
    onSelect={switchContext}
    renderTarget={(active, opened, {pending}) => (
        <Button variant="subtle" loading={pending}>
            {active?.label ?? 'Choose context'}
        </Button>
    )}
/>

Stable data-testid hooks for end-to-end tests: context-switcher-target, context-switcher-dropdown, context-switcher-search, context-switcher-item-<id>, context-switcher-action-<id>, context-switcher-empty. The pending item and the dropdown carry data-pending while a switch is in flight.

Command Palette

CommandPalette is a ⌘K command palette built on @mantine/spotlight. It auto-flattens your nav-item tree into searchable destinations, accepts extra non-navigation actions, ranks results with a lightweight fuzzy matcher, and shows Recently Viewed / Starred sections when the search box is empty.

@mantine/spotlight is a peer dependency, install it and import its stylesheet once in your app:

npm install @mantine/spotlight
import {CommandPalette, useCommandPalette} from '@ethanhann/mantine-nav';
import '@mantine/core/styles.css';
import '@mantine/spotlight/styles.css'; // required

function App() {
    const palette = useCommandPalette();

    return (
        <>
            <button onClick={palette.open}>Search…</button>

            <CommandPalette
                items={items}
                actions={[
                    {
                        id: 'theme',
                        label: 'Toggle color scheme',
                        keywords: ['dark', 'light'],
                        onSelect: toggleColorScheme
                    },
                    {id: 'logout', label: 'Log out', onSelect: signOut},
                ]}
                // For SPA routing, navigate via your router instead of a full page load:
                onNavigate={(command) => router.push(command.href)}
            />
        </>
    );
}

The palette opens on ⌘K / Ctrl+K by default (configurable via shortcut, or null to disable) and shares a single instance with useCommandPalette() so a trigger button and the shortcut drive the same palette.

Flattening preserves each item's generic data payload: flattenNavCommands<TData>(items) returns NavCommand<TData>[], so the source item's data is available on every command for use in onNavigate and custom result rendering.

Backend search source

Pass an async search function to surface results from a backend (docs, records, etc.) alongside the local matches. Local nav/actions match instantly; backend results stream into a Results group appended below them:

<CommandPalette
    items={items}
    search={async (query, signal) => {
        const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`, {signal});
        const hits = await res.json();
        return hits.map((h) => ({id: h.id, label: h.title, href: h.url, description: h.section}));
    }}
    onNavigate={(command) => router.push(command.href)}
/>

The search function receives an AbortSignal, forward it to fetch so superseded requests are canceled. Behavior follows established search-UX conventions out of the box: the query is **debounced 200ms ** (Algolia's recommended delay, tunable via searchDebounce), fires only once the query reaches minSearchLength (default 2; 0 also fires on the empty query for server-side suggestions on open), keeps previous results visible while the next request loads — including when it fails (stale-while-revalidate), shows a spinner only once a request stalls (searchStallThreshold, default 300ms, so fast responses don't flicker one), dedups backend hits that share an href with a displayed local match, caps the Results group at limit like the local groups, and shows "Searching…" rather than flashing "Nothing found" while a request is in flight. A failed search shows searchErrorMessage in the empty slot, or a warning icon in the search input when local rows are still matching.

Memoize the search function (e.g. useCallback): swapping in a different function refetches the current query (so workspace-scoped searches stay fresh), which means an unmemoized inline function costs a superseded request per parent render.

Advanced exports

  • useCommandSearch(options) is the backend-search state machine behind the palette (debounce, abort, supersession, stale-while-revalidate). Use it directly to build a custom search UI.
  • commandPaletteStore and commandPaletteControls expose the shared Spotlight store for imperative open, close, and toggle calls outside React.
  • fuzzyMatch(query, text) and rankCommands(query, commands) are the ranking primitives used for local matches.

SSR and Next.js

The published build preserves "use client" directives, so components and hooks work in the Next.js App Router without a manual client boundary.

The persistence hooks (usePinnedItems, useRecentlyViewed, useStarredPages, usePersistedList, useSidebarResize) read localStorage in a lazy initializer. On the server they return their defaults, and the stored value appears after hydration on the client. Gate markup that depends on persisted state with useHydrated() to avoid hydration mismatches:

import {useHydrated, usePinnedItems} from '@ethanhann/mantine-nav';

const hydrated = useHydrated();
const {pinnedItems} = usePinnedItems(items, {storageKey: 'nav-pins'});
if (!hydrated) return <NavGroup items={items}/>;

useIsSSR() returns true during server rendering and the first client render.

Hooks

useNavShell / useOptionalNavShell

Access the shell's sidebar and mobile-drawer state from any descendant of NavShell:

import {useNavShell} from '@ethanhann/mantine-nav';

function MenuButton() {
    const {isMobile, toggleMobile, desktopCollapsed, toggleDesktop} = useNavShell();
    return (
        <button onClick={isMobile ? toggleMobile : toggleDesktop}>
            Menu
        </button>
    );
}

Use useOptionalNavShell() when the component may render outside a NavShell.

useActiveNavItem

const {activeItem, activeHref, isActive} = useActiveNavItem(items, {
    currentPath: '/products/inventory',
    matcher: 'prefix',
});

useHeadlessSidebar

Sidebar behavior without any UI — for fully custom sidebars:

const sidebar = useHeadlessSidebar({
    items,
    defaultExpanded: ['products'],
});
// { expandedKeys, collapsed, toggleGroup, getItemProps, getGroupProps, ... }

Other Hooks

Hook Purpose
useCommandSearch Backend-search state machine used by CommandPalette
useCurrentPath Reactive pathname for active matching
useNavItems Flatten, expand/collapse, and traverse item trees
useExpandedKeys Headless expand/collapse state for a tree (toggle/expand-all/collapse-all)
useNavKeyboard Arrow keys, Home/End, Enter/Space, Escape, type-ahead
useNavAnimation Transition config that respects prefers-reduced-motion
useNavColorScheme Read and toggle light/dark color scheme
useNavRegistry Flat dot-notation registration of nav entries
useNavVars Read/write CSS custom properties, restoring prior values on reset
useSidebarResize Drag-to-resize sidebar with localStorage persistence
useSidebarVariant Cycle sidebar between full, rail, mini
useResponsiveNav Mobile/tablet/desktop breakpoint state and helpers
useReorderableNav Drag-and-drop reordering of nav items
useRemoteNavItems Hydrate items from an async source, re-hydrating when resolvers change
usePinnedItems Pin/unpin favorites (localStorage-backed)
useRecentlyViewed Track recently visited pages (localStorage-backed)
useStarredPages Star/bookmark pages (localStorage-backed)
usePersistedList Ordered, localStorage-backed list primitive (add/remove/toggle/reorder/upsertFirst)
useIsSSR / useHydrated SSR-safety helpers

Utilities

Function Purpose
filterVisibleItems(items) Recursively drop items where visible evaluates to false; prunes empty groups
isItemVisible(item) Resolve an item's visible flag (boolean or function)
sortItemsByWeight(items) Stable sort by weight (lower first); recurses into group children
walkNavTree(items, visit) Depth-first traversal; return false from visit to skip a group's children
flattenNavTree(items) Flatten a nav tree depth-first into a single list
flattenNavCommands(items) Flatten link items into NavCommand[] for command palettes
fuzzyMatch(query, text) Lightweight fuzzy matcher returning a score and matched indices
rankCommands(query, cmds) Rank commands with fuzzyMatch, best first

Development

Storybook

npm install
npm run dev             # starts Storybook at http://localhost:6006
npm run storybook:build # build static Storybook site

Stories are organized by area:

Category What's covered
Shell NavShell variants, NavHeader, NavSidebar, mobile drawer viewports, router linkComponent integration
NavGroup Core tree, external links / onClick items, custom renderItem, weight-based ordering
Customization Controlled state, localization, slot styling, loading skeletons, collapsed rail
SaaS WorkspaceSwitcher, UserMenu, PlanBadge, NotificationIndicator
ContextSwitcher Generic context/persona switching — async pending, sections, badges, custom trigger
Hooks useNavRegistry, useRemoteNavItems, useSidebarResize, useReorderableNav, usePinnedItems
Recipes Full-page layouts — admin dashboard, SaaS platform, documentation site

A color scheme toggle in the Storybook toolbar renders every story in light or dark mode.

Tests

npm run test:run        # single run
npm run test            # watch mode
npm run test:coverage   # with v8 coverage

Build

npm run build           # build the library to dist/ (ESM + .d.ts + sourcemaps)
npm run typecheck       # type-check without emitting

License

MIT

About

Resources

License

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages