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_statecookie — a second source of truth next toShellProvider, and the wrong shape for a SPA where each workspace remembers its own state. - It needs a
matchMediahook 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/inboxPick a row and the path above changes — active matching, open branches and the collapse button are all live. Nothing navigates: the demo passes its own LinkComponent.
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.
'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/inboxPick a row and the path above changes — active matching, open branches and the collapse button are all live. Nothing navigates: the demo passes its own LinkComponent.
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.
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.
Sidebarpullsnav,currentApp,workspaces,sidebarCollapsed,toggleSidebarandlabelsout ofuseShell(). Rendered outside aShellProviderit throws rather than rendering empty. The provider also supplies theTooltipProviderthe icon rail needs. - Active matching is exact first. The sidebar flattens every group with
flattenNavItems()and hands the whole list down assiblings, so a prefix match only wins when no row matches the pathname exactly —/tasksgoes 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 throughsetSidebarCollapsed. - 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 utilitiesw-sidebarandw-sidebar-icon, with a 200ms transition between them. The switcher strip ish-header. Override the tokens, not the component. - Below
mdit renders nothing. The<aside>ishidden md:flex— pure CSS, no viewport measurement, no hydration mismatch. Narrow screens getMobileMenu, an off-canvas Sheet driven bymobileMenuOpen, 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-accentand--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
| Prop | Type | Default | Description |
|---|---|---|---|
header | ReactNode | — | Slot directly under the workspace switcher — a "New task" button, a quick search box. Its padding follows the collapse state. |
footer | ReactNode | — | Slot 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. |
showWorkspaceSwitcher | boolean | true | Show 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. |
showCollapseButton | boolean | true | Show 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. |
className | string | — | Extra 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
| Prop | Type | Default | Description |
|---|---|---|---|
grouprequired | NavGroup | — | The 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). |
collapsed | boolean | false | Icon-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. |
siblings | NavItem[] | [] | 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 | () => void | — | Forwarded to each item. Fires when a real link is clicked, not when a branch is only opened. MobileMenu uses it to close itself. |
className | string | — | Extra classes on the group wrapper. |
SidebarMenuItem props
| Prop | Type | Default | Description |
|---|---|---|---|
itemrequired | NavItem | — | The 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. |
collapsed | boolean | false | Render the icon tile plus a right-side Tooltip carrying the label. Applies to depth 0 only — nested rows never collapse to the rail. |
depth | number | 0 | Nesting level. Above 0 the row is indented, set one step smaller, and never becomes an icon tile. SidebarMenuItem sets it for its own children. |
siblings | NavItem[] | [] | 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 | () => void | — | Called on a real link click only — opening or closing a branch does not fire it. |
className | string | — | Extra 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.
| Prop | Type | Default | Description |
|---|---|---|---|
nav | NavItem[] | NavGroup[] | — | Menu of the current app. A flat array is normalised into one unlabelled group by toNavGroups(). |
workspaces | Workspace[] | [] | Fills the switcher at the top of the sidebar, and supplies the key the collapse state is remembered under. |
pathname | string | usePathname() | Route used for active matching. Leave it out on Next; pass it in Storybook or any non-Next app. |
searchParams | URLSearchParams | string | object | null | — | Only 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. |
defaultSidebarCollapsed | boolean | false | Collapse state used for SSR. After mount the stored per-workspace choice wins. |
collapseSidebarOnTablet | boolean | true | Between 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. |
persistSidebarState | boolean | true | Remember the collapse state in localStorage under comitor-sidebar:<workspaceId>. Set false for Storybook or kiosk mode. |
enableShortcuts | boolean | true | Global 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. |
LinkComponent | ComponentType<ShellLinkProps> | next/link | Router link used by every nav row. Swap it in a non-Next app. |
labels | Partial<ShellLabels> | DEFAULT_SHELL_LABELS | Display 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 withlabels.navigationLabel. A group with no header — an unlabelled group, one withcollapsible: false, or any group in the icon rail — is arole="group"carryinggroup.labelas 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-nonestops 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 haschildrenis the exception: it is a genuinelydisabled<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-labeland the short one as visible text. KeepcollapseSidebarShorta substring ofcollapseSidebarwhen translating — WCAG 2.5.3, Label in Name. - Group labels and branch chevrons use
--muted-foregroundinstead of an alpha step, which is the only way both themes clear 4.5:1 for the text and 3:1 for the chevron.