Components

Sidebar

The application sidebar of the Tier 3 shell: workspace switcher on top, the current app’s menu in the middle, a 56px icon rail when collapsed. It renders a NavGroup[] data model rather than children.

shadcn’s ui/sidebar is not part of this design system

SidebarProvider, SidebarTrigger, SidebarInset, SidebarMenuButton and the rest of that primitive are absent from every entry point of @comitor/ui. That is a decision, not an omission:

  • It collides on exactly three names — Sidebar, SidebarGroup, SidebarMenuItem — with the shell that already owns the sidebar concern, and the two APIs have nothing in common. A collision across entry points raises no error, so the wrong import builds a mute sidebar in silence.
  • It keeps the collapse state in a sidebar_state cookie — a second source of truth next to ShellProvider, and the wrong shape for a SPA where each workspace remembers its own state.
  • It needs a matchMedia hook to know it is on mobile. The shell is responsive in CSS alone, so nothing has to guess the viewport during the first server render.

Use instead: Sidebar, SidebarGroup and SidebarMenuItem from @comitor/ui/shell, documented below — or AppShell, which mounts one for you.

Genuinely need the shadcn one? Run npx shadcn@latest add sidebar in your own repo. It is upstream shadcn code — the only Comitor-specific parts are cn() and the tokens, both of which you already have. Keep it under its own import path so the three shared names can never be confused with the shell ones.

Live demo

Sidebar reads useShell(), so it only renders inside a ShellProvider. Everything it shows — the switcher, the groups, the badge, the disabled row, the nested branch — comes from the nav and workspaces props.

/tasks/inbox

Your viewport is under the md breakpoint, so Sidebar renders nothing at all — that is the real behaviour, and an app puts MobileMenu here instead.

Inside AppShell

Apps rarely mount the sidebar themselves. AppShell wires ShellProvider + Sidebar + AppHeader + MobileMenu in one frame and forwards this component’s props through sidebarProps. In the header-first layout it also flips showWorkspaceSwitcher and showCollapseButton off, because the header has taken both over.

app/(tasks)/layout.tsx
'use client'

import type { ReactNode } from 'react'
import { AppShell } from '@comitor/ui/shell'
import { NAV } from './nav'
import { NewTaskButton } from './new-task-button'
import { StorageMeter } from './storage-meter'

export function TasksLayout({ children }: { children: ReactNode }) {
  return (
    <AppShell
      nav={NAV}
      workspaces={[{ id: 'acme', name: 'Acme Corp', slug: 'acme', plan: 'Business' }]}
      user={{ id: 'u1', name: 'Bình Phan', email: '[email protected]' }}
      // Everything below is forwarded to <Sidebar />.
      sidebarProps={{
        header: <NewTaskButton />,
        footer: <StorageMeter />,
      }}
    >
      {children}
    </AppShell>
  )
}

Collapsed rail

Collapsed, the sidebar is 56px of icon tiles: group labels go (there is no room), badges shrink to a dot, and every row keeps its label in a tooltip on the right. The footer slot is not rendered at this width. Use the button at the bottom to expand it again.

/tasks/inbox

Your viewport is under the md breakpoint, so Sidebar renders nothing at all — that is the real behaviour, and an app puts MobileMenu here instead.

Composing your own frame

SidebarGroup and SidebarMenuItem are exported so an app with its own chrome can still render the shell’s nav — the collapsible group header, the nested branches, the active matching, the badges. They stay inside a ShellProvider either way.

custom-rail.tsx
import { SidebarGroup, flattenNavItems } from '@comitor/ui/shell'
import type { NavGroup } from '@comitor/ui/shell'

/**
 * Own chrome, shell nav rendering. Must still sit inside a <ShellProvider>:
 * SidebarGroup delegates to SidebarMenuItem, which reads useShell() for the
 * pathname, the link component and the labels.
 */
export function CustomRail({ nav }: { nav: NavGroup[] }) {
  // Pass EVERY item of the app as `siblings`, not just the ones in this group —
  // otherwise "/tasks" stays lit while you are standing on "/tasks/inbox".
  const items = flattenNavItems(nav)

  return (
    <aside className="flex w-sidebar flex-col border-r border-sidebar-border bg-sidebar">
      <nav aria-label="Main navigation" className="flex flex-1 flex-col gap-4 overflow-y-auto p-2">
        {nav.map((group) => (
          <SidebarGroup key={group.id} group={group} siblings={items} />
        ))}
      </nav>
    </aside>
  )
}

How it works

  • Shell context is required. Sidebar pulls nav, currentApp, workspaces, sidebarCollapsed, toggleSidebar and labels out of useShell(). Rendered outside a ShellProvider it throws rather than rendering empty. The provider also supplies the TooltipProvider the icon rail needs.
  • Active matching is exact first. The sidebar flattens every group with flattenNavItems() and hands the whole list down as siblings, so a prefix match only wins when no row matches the pathname exactly — /tasks goes dark once you stand on /tasks/inbox.
  • Collapse state lives in the shell, in localStorage. Keyed comitor-sidebar:<workspaceId>, so each workspace remembers its own — no cookie, no second provider. Toggle it from the footer button, from ⌘/Ctrl + B, or through setSidebarCollapsed.
  • Tablet starts collapsed. Between 768px and 1023px a 256px rail eats a third of the width, so a reader who has never toggled gets the icon rail. Measured once on mount, never on resize, and never written back to storage — a default, not a decision.
  • Width comes from two tokens. --sidebar-width (16rem / 256px) and --sidebar-width-icon (3.5rem / 56px) surface as the utilities w-sidebar and w-sidebar-icon, with a 200ms transition between them. The switcher strip is h-header. Override the tokens, not the component.
  • Below md it renders nothing. The <aside> is hidden md:flex — pure CSS, no viewport measurement, no hydration mismatch. Narrow screens get MobileMenu, an off-canvas Sheet driven by mobileMenuOpen, which reorders the same content: workspace, current app’s menu, app list, user.
  • Colour comes from the sidebar tokens. --sidebar, --sidebar-foreground, --sidebar-border, --sidebar-accent and --sidebar-accent-foreground, so both palettes and both themes stay correct. The active row deliberately does not use the app accent: gold on the active tint measures 1.70:1, which is invisible exactly when it has something to say.

Sidebar props

PropTypeDefaultDescription
headerReactNodeSlot directly under the workspace switcher — a "New task" button, a quick search box. Its padding follows the collapse state.
footerReactNodeSlot above the collapse button (storage meter, app version, "Invite members"). Rendered only while the sidebar is expanded — the 56px rail has no room for it.
showWorkspaceSwitcherbooleantrueShow the WorkspaceSwitcher strip at the top. The strip is dropped anyway when workspaces is empty, so a single-tenant app leaves no empty band. AppShell sets it to false in the header-first layout, where the switcher moves into the header.
showCollapseButtonbooleantrueShow the collapse/expand button pinned at the bottom. Turn it off when the header already carries the toggle — AppShell does exactly that for header-first.
classNamestringExtra classes on the <aside>. Width, background, border and the 200ms width transition come from the component; use this for positioning inside a custom frame.

SidebarGroup props

PropTypeDefaultDescription
grouprequiredNavGroupThe group to render. Without a label it renders as a bare list with no header; with a label it is collapsible by default (group.collapsible and group.defaultCollapsed override that).
collapsedbooleanfalseIcon-rail mode: the group label disappears (there is no room for it) and the items render as centred icon tiles, spacing between groups being the only thing that survives.
siblingsNavItem[][]Every item of the current app — flattenNavItems(nav) — used for exact-path matching. Sidebar passes it for you; a custom frame must pass it itself.
onNavigate() => voidForwarded to each item. Fires when a real link is clicked, not when a branch is only opened. MobileMenu uses it to close itself.
classNamestringExtra classes on the group wrapper.

SidebarMenuItem props

PropTypeDefaultDescription
itemrequiredNavItemThe row to render: label, href, icon, badge, disabled, external, and children for a nested branch. A parent with children opens and closes instead of navigating.
collapsedbooleanfalseRender the icon tile plus a right-side Tooltip carrying the label. Applies to depth 0 only — nested rows never collapse to the rail.
depthnumber0Nesting level. Above 0 the row is indented, set one step smaller, and never becomes an icon tile. SidebarMenuItem sets it for its own children.
siblingsNavItem[][]Set of items used to decide the active row. Without it a prefix match wins and /tasks stays lit while you stand on /tasks/inbox.
onNavigate() => voidCalled on a real link click only — opening or closing a branch does not fire it.
classNamestringExtra classes merged onto the expanded row. The collapsed icon tile builds its own class list and ignores this — style the rail through Sidebar className or the sidebar tokens instead.

ShellProvider props that drive the sidebar

A subset of ShellProviderProps AppShell accepts every one of them too and passes them straight through.

PropTypeDefaultDescription
navNavItem[] | NavGroup[]Menu of the current app. A flat array is normalised into one unlabelled group by toNavGroups().
workspacesWorkspace[][]Fills the switcher at the top of the sidebar, and supplies the key the collapse state is remembered under.
pathnamestringusePathname()Route used for active matching. Leave it out on Next; pass it in Storybook or any non-Next app.
searchParamsURLSearchParams | string | object | nullOnly needed when two rows differ by query string (/settings?tab=general vs ?tab=members). The shell never calls useSearchParams() itself — that would force a Suspense boundary onto the page that imported it.
defaultSidebarCollapsedbooleanfalseCollapse state used for SSR. After mount the stored per-workspace choice wins.
collapseSidebarOnTabletbooleantrueBetween 768px and 1023px, start collapsed when the user has never toggled. Read once on mount, never on resize, and never written to storage — it is a default, not a decision.
persistSidebarStatebooleantrueRemember the collapse state in localStorage under comitor-sidebar:<workspaceId>. Set false for Storybook or kiosk mode.
enableShortcutsbooleantrueGlobal keys: ⌘/Ctrl+B collapses the sidebar, ⌘/Ctrl+K the command palette, ⌘/Ctrl+Shift+K the app launcher, Alt+1…9 switches app. Those four are ignored while an input, textarea, select or contenteditable has focus; Esc closes every overlay and is the one key that still fires while typing.
LinkComponentComponentType<ShellLinkProps>next/linkRouter link used by every nav row. Swap it in a non-Next app.
labelsPartial<ShellLabels>DEFAULT_SHELL_LABELSDisplay strings, merged key by key over the Vietnamese defaults. An English app overrides navigationLabel, collapseSidebar, collapseSidebarShort, expandSidebar and the workspace keys.

Accessibility

  • The menu is a real <nav> labelled with labels.navigationLabel. A group with no header — an unlabelled group, one with collapsible: false, or any group in the icon rail — is a role="group" carrying group.label as its accessible name. A labelled collapsible group needs no such role: its visible header button names it already.
  • The active row is marked aria-current="page" and is set apart by background, text colour and weight at once — never by a colour-only bar.
  • Group headers and parent rows are <button aria-expanded>, so a branch announces whether it is open before it is entered.
  • Disabled rows are <span role="link" aria-disabled tabIndex={0}>, not a dimmed <a>: pointer-events-none stops the mouse but not the keyboard. They stay in the tab order on purpose, and are tinted with --muted-foreground (5.01:1 light / 5.35:1 dark) rather than an opacity that would drop them below the threshold. A disabled row that has children is the exception: it is a genuinely disabled <button> and leaves the tab order, because Enter on it would only fail to open a subtree.
  • In the icon rail every row keeps a tooltip carrying its label and count, plus an aria-label, so a screen reader hears the same thing at both widths.
  • The collapse button carries the long string in its aria-label and the short one as visible text. Keep collapseSidebarShort a substring of collapseSidebar when translating — WCAG 2.5.3, Label in Name.
  • Group labels and branch chevrons use --muted-foreground instead of an alpha step, which is the only way both themes clear 4.5:1 for the text and 3:1 for the chevron.

Related