Components

Navigation Model

The data contract the shell drives its sidebar and mobile menu from: a tree of items and groups, plus the pure functions that decide which row is the current page.

Looking for the old SidebarNav?

The bespoke SidebarNav / SidebarNavGroup / SidebarNavItem set only ever existed inside this documentation site and never shipped in @comitor/ui. Its job is now split in two: the chrome — rail, collapse, rows, groups — lives in the Tier 3 Sidebar, and the data it renders is this page. You no longer build the navigation out of JSX; you describe it as an array and hand it to AppShell.

The data contract

A menu is ShellNav, which is either a flat NavItem[] or a grouped NavGroup[]. Everything below is plain data — no JSX, no hooks — so it can live in a shared module that a Server Component reads too. This is the model the rest of the page uses.

nav.ts
import type { NavGroup } from '@comitor/ui/shell'
import {
  Activity,
  CalendarDays,
  FolderKanban,
  Inbox,
  LayoutDashboard,
  Settings,
  Users,
} from 'lucide-react'

export const TASKS_NAV: NavGroup[] = [
  {
    id: 'main',
    // No label — the first group is usually unlabelled and never collapsible.
    items: [
      { id: 'overview', label: 'Overview', href: '/tasks', icon: LayoutDashboard },
      { id: 'inbox', label: 'Inbox', href: '/tasks/inbox', icon: Inbox, badge: 4 },
      { id: 'today', label: 'Today', href: '/tasks/today', icon: CalendarDays },
    ],
  },
  {
    id: 'workspace',
    label: 'Workspace',
    items: [
      {
        id: 'projects',
        label: 'Projects',
        href: '/tasks/projects',
        icon: FolderKanban,
        // Children render inside a Collapsible; clicking the parent only toggles.
        children: [
          { id: 'projects-active', label: 'Active', href: '/tasks/projects/active' },
          { id: 'projects-archived', label: 'Archived', href: '/tasks/projects/archived' },
        ],
      },
      { id: 'members', label: 'Members', href: '/tasks/members', icon: Users },
    ],
  },
  {
    id: 'settings',
    label: 'Settings',
    defaultCollapsed: true,
    items: [
      { id: 'settings-general', label: 'General', href: '/tasks/settings', icon: Settings },
      { id: 'settings-notifications', label: 'Notifications', href: '/tasks/settings?tab=notifications' },
      {
        id: 'settings-billing',
        label: 'Billing',
        href: '/tasks/settings?tab=billing',
        badge: 'Pro',
        badgeVariant: 'warning',
      },
      {
        id: 'status',
        label: 'System status',
        href: 'https://status.comitor.vn',
        icon: Activity,
        external: true,
      },
    ],
  },
]

Handing it to the shell

The shell takes it from there: it normalises the shape, resolves the current app, and renders the same model into both the desktop sidebar and the mobile menu. searchParams is optional and only matters when two items differ by query alone — see active matching.

shell.tsx
'use client'

import type { ReactNode } from 'react'
import { useSearchParams } from 'next/navigation'
import { AppShell } from '@comitor/ui/shell'
import { TASKS_NAV } from './nav'

export function Shell({ children }: { children: ReactNode }) {
  // The shell never calls useSearchParams() itself — that would force every
  // page under it into a Suspense boundary. The app opts in, from a client
  // component that already has one.
  const searchParams = useSearchParams()

  return (
    <AppShell nav={TASKS_NAV} searchParams={searchParams}>
      {children}
    </AppShell>
  )
}

Normalising and flattening

toNavGroups collapses both accepted shapes into one, so nothing downstream has to branch on which one the app used. flattenNavItems then walks the groups and their children into a single array. An app rarely calls the first one — the shell already did it — but it calls the second one every time it asks about active state.

tsx
import { flattenNavItems, toNavGroups } from '@comitor/ui/shell'
import type { NavGroup, NavItem, ShellNav } from '@comitor/ui/shell'

// ShellNav = NavItem[] | NavGroup[] — a small app can skip groups entirely.
const flat: ShellNav = [
  { id: 'inbox', label: 'Inbox', href: '/tasks/inbox' },
  { id: 'today', label: 'Today', href: '/tasks/today' },
]

const groups: NavGroup[] = toNavGroups(flat)
// → [{ id: 'default', items: [ …the two items… ] }]

const siblings: NavItem[] = flattenNavItems(groups)
// → every item of every group, children included, in render order

Active matching

isNavItemActive is deliberately not pathname === href. It follows five rules, in order. The first three settle the path; the last two settle the query, and apply only once rule 2 has matched the path and the app actually passed searchParams.

  1. An explicit item.isActive wins outright — that is the escape hatch for detail routes with no item of their own.
  2. An exact path match wins next. The query is split off the href first, because Next’s usePathname() never contains one — compare the raw strings and /settings?tab=billing could never light up at all.
  3. A prefix match is the fallback, and it is allowed only when no other item matches the pathname exactly. Otherwise /tasks would stay lit while you stand on /tasks/inbox, which has a row of its own.
  4. An item that carries a query lights when that query is a subset of the current URL’s, not when the two are equal. /tasks/settings?tab=billing stays lit at /tasks/settings?tab=billing&page=2. Page numbers, sort order and search terms accumulate in the URL as people use the page, and a row that went dark the moment someone paged or filtered would be worse than one that never lit at all.
  5. An item with no query yields to a same-path item whose query matches. Without this, “General” at /tasks/settings would stay lit beside the open “Billing” tab — the default tab and the one you are actually on, both claiming to be where you are. Hand the shell no searchParams and there is nothing to yield to: the query-less row lights on its own and the tabs stay dark.

The case rule 5 cannot save you from. Rule 5 needs a query-less row on that path to hand the highlight to. Give a path only query rows — ?tab=general, ?tab=members, ?tab=billing and no plain /settings — and then withhold searchParams, and all three light at once. There is no error and no warning; the menu simply stops answering “where am I”. Either pass searchParams, or keep one query-less row as the canonical one.

Rule 3 is why siblings must be every item of the app rather than the items of one group: the parent that would wrongly light up and the exact match that should suppress it are usually in different groups. The helpers are pure functions, so the table below is the real thing — pick a route and watch the answers change.

Current route

ItemhrefisNavItemActiveisNavBranchActive
Unlabelled group
Overview/tasksfalsefalse
Inbox/tasks/inboxfalsefalse
Today/tasks/todayfalsefalse
Workspace
Projects/tasks/projectsfalsetrue
Active/tasks/projects/activetruetrue
Archived/tasks/projects/archivedfalsefalse
Members/tasks/membersfalsefalse
Settings
General/tasks/settingsfalsefalse
Notifications/tasks/settings?tab=notificationsfalsefalse
Billing/tasks/settings?tab=billingfalsefalse
System statushttps://status.comitor.vnfalsefalse

Four things to try

  • On /tasks/projects/active, turn Match against every item off. “Overview” (/tasks) lights up, because its own group contains nothing that matches the pathname exactly — the exact match lives one group down.
  • On the same route, “Projects” is false but its branch is true. That is the difference between painting a row and keeping a collapsed branch open.
  • On /tasks/settings?tab=billing, the shell without the query lights “General” alone — one canonical row rather than three tabs all claiming to be where you are. Switch Shell knows the query on and “Billing” takes over.
  • Move to /tasks/settings?tab=billing&page=2 with the query still on. “Billing” stays lit even though its href knows nothing about page — that is rule 4 — and “General” stays dark, which is rule 5.

Which app am I in?

Navigation has two perpendicular axes: the workspace you are in, and the product you are using. The menu above belongs to one product. resolveCurrentApp answers the second axis from the pathname, and appAccentStyle turns that app’s colours into the CSS variables the sidebar and mobile menu read. Declare all three colour roles or none: the fill and the ink pull in opposite directions, so deriving one from the other leaves the ink at the theme default while the fill changes. Full detail on the app axis lives at App Launcher.

tsx
import { Contact, House } from 'lucide-react'
import { appAccentStyle, resolveCurrentApp } from '@comitor/ui/shell'
import type { AppDescriptor } from '@comitor/ui/shell'

export const APPS: AppDescriptor[] = [
  { id: 'home', name: 'Home', icon: House, href: '/' },
  {
    id: 'crm',
    name: 'CRM',
    icon: Contact,
    href: '/crm',
    // All three roles, or the ink stays gold while the fill turns teal.
    accent: 'var(--color-teal)',
    accentForeground: 'var(--color-teal-foreground)',
    accentInk: 'var(--color-teal-ink)',
  },
]

export function AppScope({ pathname, children }: { pathname: string; children: React.ReactNode }) {
  // Longest path prefix wins, so '/crm/deals' never falls back to the app at '/'.
  const currentApp = resolveCurrentApp(APPS, pathname)

  // undefined when the app declares no colours — the theme defaults stand.
  return <div style={appAccentStyle(currentApp)}>{children}</div>
}

ShellNavLink

Every navigable row in the shell renders through ShellNavLink. It does two things a bare anchor cannot: it uses whatever link component the shell was given, and it reports each click back to the shell so panels and off-canvas menus close themselves.

tsx
'use client'

import { ShellNavLink } from '@comitor/ui/shell'

// Inside the shell, this is the link every nav row uses. It reads LinkComponent
// off the shell context and fires onNavigate(href) after each click, which is
// how the mobile menu and the app launcher close themselves.
export function NavLinks() {
  return (
    <>
      <ShellNavLink href="/tasks/inbox" aria-current="page">
        Inbox
      </ShellNavLink>

      {/* external → a plain <a target="_blank">; a router cannot go off-domain. */}
      <ShellNavLink href="https://status.comitor.vn" external>
        System status
      </ShellNavLink>
    </>
  )
}

The LinkComponent escape hatch

The default is next/link, which is why next is a peer dependency of @comitor/ui/shell. Outside Next, pass a component of your own. Its props are typed as ShellLinkProps href is a plain string, never Next’s UrlObject, precisely so another router can be adapted to it. Pass pathname too: without an App Router there is nothing for the shell to read it from.

tsx
import type { ReactNode } from 'react'
import { Link as RouterLink, useLocation } from 'react-router-dom'
import { AppShell } from '@comitor/ui/shell'
import type { ShellLinkProps } from '@comitor/ui/shell'
import { TASKS_NAV } from './nav'

// The shell defaults to next/link. Outside Next — Vite, Remix, Storybook —
// supply any component that accepts { href: string } plus anchor props.
function ReactRouterLink({ href, ...props }: ShellLinkProps) {
  return <RouterLink to={href} {...props} />
}

export function Shell({ children }: { children: ReactNode }) {
  const location = useLocation()

  return (
    <AppShell
      nav={TASKS_NAV}
      LinkComponent={ReactRouterLink}
      // usePathname() returns null without an App Router — pass the path yourself.
      pathname={location.pathname}
    >
      {children}
    </AppShell>
  )
}

MobileMenu

Below md the sidebar goes off-canvas and MobileMenu takes over — a Sheet sliding in from the left, rendering the same NavGroup[] through the same matchers. Its order is deliberately different from the desktop rail: workspace switcher, then the current app’s menu, then the list of other apps, then the user. On desktop the app list sits in the header where it costs the menu nothing; here they share one scroll area, and an ecosystem of eight products would push the menu people actually opened below the fold. It closes itself on a route change and on any click that lands on a real link — expanding a branch leaves it open.

tsx
'use client'

import { MobileMenu } from '@comitor/ui/shell'

// AppShell already renders MobileMenu. Mount it yourself only when you are
// assembling the frame by hand out of ShellProvider + Sidebar + AppHeader.
export function Menu() {
  return (
    <MobileMenu
      showAppLauncher
      showThemeToggle
      footer={<p className="text-xs text-muted-foreground">v1.4.0</p>}
    />
  )
}

NavItem

PropTypeDefaultDescription
idrequiredstringStable key. Used as the React key and to look an item up; it never appears in the UI.
labelrequiredstringVisible text, and the accessible name of the row.
hrefrequiredstringTarget path. May carry a query such as /settings?tab=billing; the matcher splits it off before comparing paths.
iconShellIconA Lucide icon, or any component that accepts className so the shell can size it.
badgenumber | stringCount or short text rendered at the end of the row.
badgeVariant'default' | 'destructive' | 'warning' | 'success''default'Maps straight onto the Badge primitive variant.
childrenNavItem[]Sub-items, rendered inside a Collapsible. A parent with children only opens and closes on click — it does not navigate.
isActivebooleanForce the active state instead of letting the shell derive it from pathname. For detail routes such as /tasks/123 that have no item of their own. Setting it to false forces the row inactive.
externalbooleanfalseOpen in a new tab through a plain anchor rather than the app router.
disabledbooleanfalseRender the row inert.

NavGroup

PropTypeDefaultDescription
idrequiredstringStable key for the group.
labelstringSmall uppercase heading. Leave it out for an unlabelled group — the usual choice for the first one.
itemsrequiredNavItem[]The rows of this group.
collapsiblebooleanBoolean(label)Whether clicking the heading collapses the whole group. A group with no label has no heading to click, so it stays expanded even if you set this to true.
defaultCollapsedbooleanfalseInitial state when the group is collapsible.

Helper signatures

All of these are pure and free of hooks and directives, so they import cleanly into a Server Component. They ship from @comitor/ui/shell.

PropTypeDefaultDescription
toNavGroups(nav: ShellNav | undefined) => NavGroup[]Normalises what the app passed. A flat NavItem[] becomes one unlabelled group with id "default"; a NavGroup[] is returned as-is; undefined or empty becomes []. The shell calls it once inside ShellProvider, so an app only needs it when rendering groups outside the shell.
flattenNavItems(groups: NavGroup[]) => NavItem[]Every item of every group in render order, children included. Its main job is producing the siblings argument the two matchers need.
isNavItemActive(item: NavItem, pathname: string, siblings?: NavItem[], searchParams?: NavSearchParams | null) => booleanIs this exact row the current page? A defined item.isActive short-circuits everything. Otherwise: exact path match wins; a prefix match is allowed only when no other item matches the pathname exactly.
isNavBranchActive(item: NavItem, pathname: string, siblings?: NavItem[], searchParams?: NavSearchParams | null) => booleanThe same question asked of a parent: true when the item itself or any descendant is active. Use it to decide whether a collapsed branch should be open, not to paint the active row.
toSearchParams(input: NavSearchParams | null | undefined) => URLSearchParams | nullCoerces the three shapes an app already has — Next’s useSearchParams(), a "?tab=x" string, or a Server Component searchParams object — to one URLSearchParams. null in, null out, and null means “unknown”, which is not the same as “no query”.
resolveCurrentApp(apps: AppDescriptor[], pathname: string) => AppDescriptor | nullWhich product of the ecosystem is the user in? Longest path prefix wins, so /crm/deals cannot fall into the app mounted at /. Falls back to the first app, and returns null only for an empty list.
appAccentStyle(app: Pick<AppDescriptor, "accent" | "accentForeground" | "accentInk"> | null | undefined) => CSSProperties | undefinedInline style carrying --app-accent, --app-accent-foreground and --app-accent-ink for one app. Returns undefined when the app declares none, leaving the theme defaults alone.

Related

Accessibility

  • The row that isNavItemActive returns true for gets aria-current="page". As long as the menu holds one query-less row for the path, only that row wins, so screen reader users hear one current page rather than four. The one exception: when every route into a page is a tab and the shell was given no query, the matching tabs light up together — better than nothing being current.
  • Active state is never carried by colour alone — the row also goes font-medium and carries aria-current, so it survives greyscale and high-contrast palettes. There is deliberately no left indicator bar: the accent that would draw it reaches only 1.7:1 against the active row’s own background in the light palette, which is below the 3:1 WCAG 1.4.11 asks of a non-text state indicator.
  • A collapsible NavGroup heading is a real button with aria-expanded; a NavItem with children behaves the same way and does not navigate.
  • label is the accessible name of the row, so it still reads correctly when the sidebar is collapsed to icons.
  • external items open through a plain anchor with rel="noreferrer noopener".
  • MobileMenu is a modal sheet: it traps focus, has a title exposed to screen readers, and closes on Escape.