The whole application frame in one component: state provider, sidebar, header, scrolling main and off-canvas menu, with both navigation axes wired in. You describe your workspaces, your products and your menu as data; the frame renders them.
AppShell is ShellProvider plus one particular arrangement of the Tier 3 parts. Everything below the provider — WorkspaceSwitcher, AppLauncher, Sidebar, AppHeader, MobileMenu, UserMenu — reads the shell model out of context and takes none of it as props. (They still take presentation props of their own: slots, alignment, a className.) That is why the workspace list and the app list are passed once, here, and never again.
Two navigation axes, not one menu
A Comitor user belongs to many workspaces, and each workspace has many products enabled. The two are perpendicular — switching workspace does not switch product, and switching product does not switch workspace — so they cannot collapse into a single flat menu.
Axis 1
Workspace — the data context
Acme Corp ▸ Beta Ltd. Fed by workspaces and currentWorkspaceId, rendered by WorkspaceSwitcher.
A third thing, often confused with these: the five display axes — light/dark, contrast, layout density, font size and theme palette. They are independent of each other and of both navigation axes, they live at the root layout rather than in the shell, and they are documented at Display Axes.
And one more, new in 3.16.0: modules — the business areas inside ONE app (Production, HR, Commerce), passed as modules and laid out by moduleLayout. A module is an internal link of the open app; an app is another domain. All four module layouts, and every other arrangement of the frame, run side by side at App Shell layouts.
Live demo
A whole frame, running, inside a 560px box. Both layouts, the sidebar on and off, two workspaces, three products — one of them locked — and a real user. The keyboard shortcuts are switched off here so the page does not take over your ⌘K, and the sidebar’s collapse state is not written to your localStorage; both are on by default in an app. (The theme toggle inside the frame is a different matter — it talks to this site’s own ThemeProvider and persists like any other theme change.)
Switch workspace at the top, switch product from the launcher button in the header — the two move independently. CRM is locked, so choosing it calls onUpsell instead of navigating. Nothing here leaves the page: the demo passes its own LinkComponent.
Things to try
Open the launcher (the grid button in the header) and pick Chat. The sidebar menu changes because the app changed — but the workspace at the top does not move. Chat also declares a teal accent, so the launcher tile it fills switches from gold.
Pick CRM. It is entitled: false, so the tile is a padlock and the click goes to onUpsell — the shell will not navigate someone into a product they cannot open.
Flip the layout to header-first. The workspace switcher moves into the header and the sidebar loses both its switcher strip and its collapse button, which the header has taken over. Neither control is ever drawn twice.
Turn showSidebar off. The <aside> is gone, not emptied — and the header, launcher and user menu carry on.
Click the sun/moon in the demo header and the whole documentation site changes theme. That is not a leak — it is the point. The frame carries no theme state of its own; it renders a ThemeToggle that talks to whichever ThemeProvider is above it, and on this page that is the one in this site’s root layout.
Narrow the window below md. The sidebar disappears in CSS alone and a hamburger appears; the off-canvas sheet is the same MobileMenu the frame already mounted for you.
What the frame mounts
ShellProvider — every prop you pass that is not in the table of AppShell’s own props is forwarded here verbatim. It also wraps the tree in a TooltipProvider with a 200ms delay, which is what the collapsed icon rail needs.
Sidebar — the desktop rail, hidden below md by CSS. Configure it through sidebarProps; it is documented in full at Sidebar.
AppHeader — 56px, three regions, with the search pill in the middle one. Configure it through headerProps; see App Header.
<main> — your children, in the frame’s page-level scroll container. The outer element is h-dvh overflow-hidden, so the page scrolls inside the shell rather than the document scrolling under it. It is not the only scroller in the frame: the sidebar’s <nav> and the mobile sheet each scroll on their own, which is what keeps a long menu from dragging your page with it.
MobileMenu — the off-canvas sheet for narrow screens, mounted in both layouts and driven by mobileMenuOpen. Its content order deliberately differs from the desktop rail; see Navigation Model.
--app-accent for the whole tree — the frame reads currentApp out of context and sets that app’s three accent variables as an inline style on the root element. This is the one job that forced the frame to be split into an inner component: useShell() can only be called inside the provider that AppShell itself renders.
ComitorLocaleProvider, only if you pass locale — and outsideShellProvider, because the provider builds its merged labels from the language’s shell table and has to see it. Without the prop nothing is added, and the frame reads the provider in your root layout. See “The locale shortcut” below.
The two layouts
sidebar-first is the Linear/Lark shape: a full-height rail on the left with the workspace switcher at its top, and the header occupying only the remaining width. header-first is the Google/Atlassian shape: one header across the whole top, the sidebar below it. The difference is not only flex-row versus flex-col — four defaults move with it so that no control appears in both places at once.
Default
sidebar-first
header-first
headerProps.showSidebarToggle
false
true
headerProps.logo
undefined
<WorkspaceSwitcher />
sidebarProps.showWorkspaceSwitcher
true
false
sidebarProps.showCollapseButton
true
false
All four are fallbacks, applied with ??after your object has been spread — so anything you set yourself wins, and only the keys you left out are filled in. The order matters: spread last and {...headerProps} would overwrite a computed default with undefined. The practical consequence: passing your own headerProps.logo in header-first replaces the workspace switcher, and axis 1 vanishes from the frame unless you put it back.
tsx
import type { ReactNode } from 'react'
import { AppShell, ComitorLockup } from '@comitor/ui/shell'
import { NAV, WORKSPACES } from './shell-model'
// Default. The sidebar runs the full height on the left, the header sits to the
// right of it, and the WorkspaceSwitcher lives at the top of the sidebar.
export function SidebarFirst({ children }: { children: ReactNode }) {
return (
<AppShell layout="sidebar-first" nav={NAV} workspaces={WORKSPACES}>
{children}
</AppShell>
)
}
// Header full width across the top, sidebar underneath it. AppShell moves the
// WorkspaceSwitcher into the header's logo slot and turns on the header's
// sidebar toggle; the sidebar drops its own switcher strip and collapse button,
// so neither control is ever drawn twice.
export function HeaderFirst({ children }: { children: ReactNode }) {
return (
<AppShell layout="header-first" nav={NAV} workspaces={WORKSPACES}>
{children}
</AppShell>
)
}
// Every one of those four defaults is only a fallback: anything you set on
// headerProps / sidebarProps yourself wins. Supplying your own logo in
// header-first therefore REPLACES the workspace switcher — put it back
// somewhere, or the workspace axis disappears from the frame.
export function HeaderFirstWithLogo({ children }: { children: ReactNode }) {
return (
<AppShell
layout="header-first"
nav={NAV}
workspaces={WORKSPACES}
headerProps={{ logo: <ComitorLockup size="xs" variant="auto" /> }}
sidebarProps={{ showWorkspaceSwitcher: true }}
>
{children}
</AppShell>
)
}
Single-area apps: showSidebar={false}
Some products have nothing to put in a rail — a reader, a single-screen console, an onboarding flow. showSidebar={false} removes the <aside> rather than rendering an empty one, and the rest of the frame is untouched: header, app launcher, user menu, and the off-canvas menu that narrow screens use regardless. Note that this is different from passing an empty nav, which still draws the rail — just with nothing in it.
tsx
import type { ReactNode } from 'react'
import { AppShell } from '@comitor/ui/shell'
import { APPS, USER, WORKSPACES } from './shell-model'
// A single-area product — an inbox, a report viewer, a settings-only console —
// has nothing to put in a rail. showSidebar={false} drops the <aside> entirely
// rather than rendering an empty one.
//
// The frame keeps everything else: header, app launcher, user menu, and the
// off-canvas MobileMenu, which is what narrow screens use anyway.
export function ReaderLayout({ children }: { children: ReactNode }) {
return (
<AppShell
showSidebar={false}
apps={APPS}
workspaces={WORKSPACES}
user={USER}
headerProps={{ title: 'Reader', showSearch: false }}
>
{children}
</AppShell>
)
}
Apps without workspaces — brand
New in 3.15.0. Both navigation axes are optional. An ordinary app — one organisation, one product, nothing to switch between — leaves workspaces and apps empty and passes brand instead: its logo and name. AppBrand then stands exactly where the WorkspaceSwitcher would — at the top of the sidebar in sidebar-first, at the start of the header in header-first, at the top of the mobile menu — and with no apps there is no launcher button either.
The name and letter tile sit where the workspace switcher was. Collapse the sidebar and only the tile remains, with the name in a tooltip; switch to header-first and they move into the header. There is no launcher button, because there are no other products to launch.
The mark falls back in order:logo (an element, drawn in a 32px box and never recoloured), then logoUrl, then a letter tile derived from name — the demo above uses the last.
Collapsed, only the mark remains. The name moves into a tooltip and the link’s aria-label; subtitle is hidden.
Workspaces win. Pass brand and a non-empty workspaces and the switcher takes the slot; the brand is not drawn. A multi-organisation app that wants both renders AppBrand itself, below.
The layout does not jump.AppBrand is measured on the switcher’s trigger — a 32px mark and two tight lines, a 36px button collapsed — and uses the same two text tokens, so moving an app between the two models leaves the sidebar’s rhythm alone.
AppBrand is exported from @comitor/ui/shell and reads useShell().brand, rendering nothing when there is none. You need it only to place the brand somewhere the shell does not.
sidebar-top.tsx
'use client'
import { AppBrand, useShell, WorkspaceSwitcher } from '@comitor/ui/shell'
// The shell places AppBrand for you. Render it yourself only to put it somewhere else —
// for instance a multi-organisation app that wants its own name ABOVE the switcher,
// since with workspaces present the switcher wins the slot.
export function SidebarTop() {
const { sidebarCollapsed } = useShell()
return (
<div className="flex flex-col gap-1">
<AppBrand collapsed={sidebarCollapsed} />
<WorkspaceSwitcher collapsed={sidebarCollapsed} />
</div>
)
}
// <AppShell brand={BRAND} workspaces={WORKSPACES}
// sidebarProps={{ showWorkspaceSwitcher: false, header: <SidebarTop /> }} />
Where it goes in a Next app
Two layouts, two jobs. The root layout owns the display axes and the language — the axes must sit above everything so the page does not flash the wrong theme during hydration, and the language must reach the pages that never render the shell. A nested layout owns the shell, one per product, and must be a client component: every callback you pass it is a function.
app/layout.tsx
// app/layout.tsx — the display axes and the language belong at the ROOT, outside AppShell.
// (PaletteProvider, the fifth axis, only where users may choose a theme palette — see Display Axes.)
import type { ReactNode } from 'react'
import { ComitorLocaleProvider } from '@comitor/ui'
import { ContrastProvider, DensityProvider, FontSizeProvider, ThemeProvider } from '@comitor/ui/shell'
import { getLocale } from 'next-intl/server'
import './globals.css' // @import "tailwindcss"; @import "@comitor/ui/styles.css";
export default async function RootLayout({ children }: { children: ReactNode }) {
// The user's language from next-intl — 'vi', 'en'… Without the provider below,
// every string in the package, the shell's included, is English.
const locale = await getLocale()
return (
// Required: the four anti-flash scripts touch <html> before React hydrates,
// so the client markup never matches the server markup here.
<html lang={locale} suppressHydrationWarning>
<body>
{/* None of the five reads another, so the nesting order is free.
ThemeProvider already defaults to attribute="class",
defaultTheme="system" and the comitor-theme storage key — pass
nothing unless you mean to override it. */}
<ComitorLocaleProvider locale={locale}>
<ThemeProvider>
<ContrastProvider>
<DensityProvider>
<FontSizeProvider>{children}</FontSizeProvider>
</DensityProvider>
</ContrastProvider>
</ThemeProvider>
</ComitorLocaleProvider>
</body>
</html>
)
}
app/(app)/layout.tsx
// app/(app)/layout.tsx — one AppShell for every route of one product.
'use client'
import type { ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { AppShell } from '@comitor/ui/shell'
import { signOut } from '@/lib/auth'
import { APPS, NAV, WORKSPACES } from './shell-model'
export default function AppLayout({ children }: { children: ReactNode }) {
const router = useRouter()
return (
<AppShell
workspaces={WORKSPACES}
currentWorkspaceId="acme"
apps={APPS}
currentAppId="tasks"
nav={NAV}
user={{ id: 'u1', name: 'Nguyễn Đức Thành', email: '[email protected]', role: 'Quản trị viên' }}
// The shell never routes by itself — it has no useRouter() anywhere,
// because that hook throws when there is no App Router (Storybook, tests).
onWorkspaceChange={(id) => router.push(`/w/${id}`)}
onAppSelect={(app) => router.push(app.href)}
onUpsell={(appId) => router.push(`/billing?app=${appId}`)}
onLogout={signOut}
// No `locale` here: the root layout's provider already covers this frame, and
// the sign-in and error pages that never render it.
>
{children}
</AppShell>
)
}
Notice that the shell never routes. It calls no useRouter() anywhere, because that hook throws without an App Router and would make the shell unusable in Storybook and in tests. Active navigation is yours, through onWorkspaceChange, onAppSelect and onNavigate; static links go through LinkComponent.
The locale shortcut
Since 3.0.0 AppShell takes a locale prop: <AppShell locale="vi"> wraps the frame in a ComitorLocaleProvider of its own. It exists for an app with no root layout of its own to put the provider in. Everyone else should leave it alone, for three reasons.
It covers the frame and nothing else. The sign-in page, the error page and every public route never render the shell, so they stay English — the provider in the root layout reaches all of them.
It starts from scratch. The provider it builds is a new one: it does not inherit messages (your wording overrides) or dateFnsLocale from a provider further up. Inside the frame, those quietly disappear.
Adding or removing it remounts the shell. With the prop the tree has one more level than without it, so toggling between the two throws the whole frame away and builds it again — the page inside it too, with its scroll position and any state it held. Keep it always present or always absent.
Keeping the state, dropping the frame
When the arrangement is wrong for you — a header of your own, a two-column split, a chrome-less embed — keep ShellProvider and place the parts yourself. They will still find their data, because they never took it as props in the first place.
custom-frame.tsx
'use client'
import type { ReactNode } from 'react'
import { AppLauncher, ShellProvider, WorkspaceSwitcher } from '@comitor/ui/shell'
import { MyOwnBreadcrumb } from './breadcrumb'
import { APPS, NAV, USER, WORKSPACES } from './shell-model'
/**
* AppShell is ShellProvider plus one particular arrangement of parts. When that
* arrangement is wrong for you, keep the provider and lay out the rest yourself:
* WorkspaceSwitcher, AppLauncher, Sidebar, AppHeader, MobileMenu and UserMenu all
* read the shell model out of context and take none of it as props.
*/
export function CustomFrame({ children }: { children: ReactNode }) {
return (
<ShellProvider nav={NAV} workspaces={WORKSPACES} apps={APPS} user={USER}>
{/* ShellProvider emits NO element of its own — context plus a TooltipProvider and
nothing else. The frame is entirely yours, including the h-dvh column that gives
<main> something to be flex-1 of; without it the scroll container has no bounded
height and the whole document scrolls instead. */}
<div className="flex h-dvh w-full flex-col overflow-hidden bg-background">
<header className="flex h-header shrink-0 items-center gap-2 border-b border-border px-3">
<WorkspaceSwitcher align="start" className="w-56" />
<AppLauncher />
<MyOwnBreadcrumb />
</header>
{/* Keep relative for the same reason AppShell's own <main> has it — see
"Why the main element is positioned" below. */}
<main className="relative flex-1 overflow-y-auto">{children}</main>
</div>
</ShellProvider>
)
}
useShell()
The hook every part of the shell uses, and the one your own components use to join in. Called outside a ShellProvider it throws — it does not return undefined and it does not quietly render an empty frame, which is the failure mode that would otherwise ship to production looking like a styling bug.
app-command-palette.tsx
'use client'
import { CommandPalette } from '@comitor/ui'
import { useShell } from '@comitor/ui/shell'
/**
* The command palette is a SLOT, not a built-in: what is searchable is the app's
* data, so the package ships the keyboard state and leaves the content to you.
* Read and write that state through useShell().
*/
export function AppCommandPalette() {
// Outside a <ShellProvider> (or <AppShell>) this THROWS — it does not return
// undefined and it does not render an empty frame.
const { commandPaletteOpen, setCommandPaletteOpen, apps, onAppSelect } = useShell()
return (
<CommandPalette
open={commandPaletteOpen}
onOpenChange={setCommandPaletteOpen}
// The shell already binds Cmd/Ctrl+K. Leaving this on gives you two
// listeners flipping one boolean — it opens and closes in the same press.
registerShortcut={false}
placeholder="Search apps, tasks, people…"
groups={[
{
id: 'apps',
label: 'Apps',
items: apps.map((app) => ({
id: app.id,
label: app.name,
icon: app.icon,
onSelect: () => onAppSelect?.(app),
})),
},
]}
/>
)
}
// …then hand it to the frame:
// <AppShell commandPaletteSlot={<AppCommandPalette />}>{children}</AppShell>
Global keyboard shortcuts
One keydown listener on window, registered by the provider and removed on unmount. Set enableShortcuts={false} and it is never attached at all.
Keys
Does
Detail
Esc
Close the command palette, the mobile menu and the app launcher
Handled BEFORE the typing guard — the only shortcut that still fires while an input has focus.
⌘/Ctrl + K
Toggle commandPaletteOpen
Tested after ⌘/Ctrl+Shift+K, since both are the “k” key. Nothing appears unless commandPaletteSlot is supplied.
⌘/Ctrl + Shift + K
Toggle appLauncherOpen
Checked first, so the Shift variant is never swallowed by the plain one.
⌘/Ctrl + B
Collapse or expand the sidebar
Goes through toggleSidebar, so the new state is persisted like a click.
Alt + 1…9
Jump to the nth entitled app — or, with two or more modules and onModuleSelect (3.16.0), pick the nth module
Matched on event.code, not event.key: on macOS Alt+1 produces “¡”. Locked apps are skipped — the index counts getEntitledApps(apps); modules count after filtering (visibleModules).
Escape is handled before the typing guard
The handler computes an isTyping flag — true when the event target is an <input>, <textarea>, <select> or any isContentEditable element — and returns early on it, so the other four shortcuts never steal a keystroke from a form field. Escape is checked first, above that return, and therefore still closes every overlay while the caret is inside the command palette’s own search box. That is the whole point: the input you most need to escape from is the one inside the thing you are escaping.
A separate effect closes the palette, the mobile menu and the launcher on every change of pathname, so an overlay never survives the navigation it triggered.
Where the collapse state is kept
localStorage, keyed per workspace. The key is comitor-sidebar:<workspaceId> (comitor-sidebar:default when there are no workspaces), holding "1" or "0". One workspace can be a dense rail and another expanded.
Deliberately not a cookie. shadcn’s sidebar primitive keeps its state in a sidebar_state cookie; adopting that here would put a second source of truth next to ShellProvider, and a single cookie is the wrong shape for a preference that varies per workspace. It is also why @comitor/ui ships no shadcn sidebar at all — see Sidebar.
Read after mount, never during render. The first client render uses exactly the defaultSidebarCollapsed the server rendered; an effect then applies the stored value. Reading storage during render would be a hydration mismatch.
The tablet default is not a decision. Between 768px and 1023px, a reader with no stored choice starts collapsed — the media query comes from layout.breakpoints in @comitor/ui/tokens, not from a number typed into the shell. It is measured once on open, never on resize (a rail that collapses while you drag the window is a jolt), and never written back: writing it would leave the user collapsed on a wide screen next time, having never chosen anything.
Blocked storage degrades quietly. Both the read and the write are wrapped in try/catch — Safari private mode and locked-down browsers throw on access. The state then simply lives for the session.
The LinkComponent escape hatch
The default is next/link, which is why next is a peer dependency of @comitor/ui/shell. Outside Next, hand the shell your own router link and its pathname.
shell.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 { NAV } from './shell-model'
// ShellLinkProps is anchor props with href narrowed to a plain string — never
// Next's UrlObject — precisely so another router can be adapted to it.
function ReactRouterLink({ href, ...props }: ShellLinkProps) {
return <RouterLink to={href} {...props} />
}
export function Shell({ children }: { children: ReactNode }) {
const location = useLocation()
return (
<AppShell
nav={NAV}
LinkComponent={ReactRouterLink}
// usePathname() returns null without an App Router, and the shell falls
// back to '' — so nothing would ever be active. Pass the path yourself.
pathname={location.pathname}
>
{children}
</AppShell>
)
}
Why the shell imports next/link.js, with the extension
The next package publishes no exports map, so next/link is nothing but a file path. A bundler adds the extension for you; plain Node ESM does not, and throws ERR_MODULE_NOT_FOUND … Did you mean next/link.js? the moment anything imports the entry. Writing next/link.js and next/navigation.js resolves to the same file a bundler would have picked, while staying loadable by Node — so client-side navigation and usePathname() behave identically.
This matters to you if you bundle @comitor/ui/shell outside Next, run it through a plain-Node smoke test, or import it from a script: the entry loads. The shell also normalises the interop shape it gets back — module.default ?? module — because a bundler hands over the component while Node hands over the namespace object.
Why the main element is positioned
<main> ships as relative flex-1 overflow-y-auto bg-background. The relative is not decoration, and contentClassName must not take it away.
overflow-y-auto does not clip an absolutely positioned descendant unless the scroll container is itself positioned: the containing block escapes to the initial one, so the element sits outside the clip and stretches the document’s scroll height. In a frame that is h-dvh overflow-hidden there is nothing to paint in that extra space, and the reader scrolls into a large blank band.
Two sources of it ship inside this very package: Tailwind’s sr-only is position: absolute with no offsets — as used for the four page labels inside TablePagination’s buttons — and Radix hides the native <input> / <select> of RadioGroup and Select the same way. Measured before the fix on a real page: scrollHeight 1531 against a clientHeight of 900 — 631px of nothing. A form-heavy page measured 892px.
contain: paint would also fix it, and is deliberately not used: it drags in paint and layout containment that nothing here asked for.
Labels — every string in the frame
Every string the shell can display is a key of ShellLabels — including the strings only a screen reader ever hears. The values come from the shell table of the nearest ComitorLocaleProvider: English when there is none, Vietnamese under locale="vi" (Set the language). So language is never what labels is for — wording is. Pass it to AppShell or ShellProvider once and every descendant picks it up from context; there is nothing to fork and nothing to wrap.
tsx
'use client'
import type { ReactNode } from 'react'
import { useComitorMessages } from '@comitor/ui'
import { AppShell, useShell } from '@comitor/ui/shell'
import type { ShellLabels } from '@comitor/ui/shell'
import { NAV } from './shell-model'
// Wording, not language: the language comes from ComitorLocaleProvider at the root.
// Partial<ShellLabels> is merged key by key over the provider's shell table, so
// overriding four keys keeps the other thirty, in the provider's language.
// This object is in ONE language — an app that runs in two keeps one per language.
const PANEL_WORDING: Partial<ShellLabels> = {
navigationLabel: 'Project navigation',
collapseSidebar: 'Collapse project panel',
// Visible text on the same button. Keep it a SUBSTRING of the long string, or
// the voice command "click Collapse" stops matching the accessible name —
// WCAG 2.5.3, Label in Name.
collapseSidebarShort: 'Collapse',
expandSidebar: 'Expand project panel',
}
export function Shell({ children }: { children: ReactNode }) {
return (
<AppShell labels={PANEL_WORDING} nav={NAV}>
{children}
</AppShell>
)
}
// Reading a string back — for a tooltip of your own, say — take it from where the
// frame takes it, so it follows the language and your overrides. Never hard-code it,
// and not DEFAULT_SHELL_LABELS either: since 3.0.0 that is the English table only.
export function InboxHint() {
const { labels } = useShell() // inside the frame: merged, overrides included
return <span>{labels.notifications}</span>
}
export function SignInHint() {
const shell = useComitorMessages('shell') // outside it: the provider's table
return <span>{shell.notifications}</span>
}
There are 34 keys since 3.7.0, which added skipToContent; before that the set had not changed since collapseSidebarShort was split out of collapseSidebar in 0.5.0. Two rules for rewording or translating them: override in pairs where one key is visible text and another is the accessible name of the same control, and keep the short string a substring of the long one, or a voice-control user saying “click Collapse” will no longer match the accessible name (WCAG 2.5.3, Label in Name). Both value columns below are read from the installed package, not typed into this page.
Key
English (default)
Vietnamese (built in)
What it names
Sidebar & header
collapseSidebar
Collapse sidebar
Thu gọn thanh bên
aria-label and tooltip of the collapse button, in the sidebar footer and in the header toggle.
collapseSidebarShort
Collapse
Thu gọn
The visible text on that same button. Must remain a substring of collapseSidebar.
expandSidebar
Expand sidebar
Mở rộng thanh bên
The same control once the rail is collapsed.
openMenu
Open menu
Mở menu
aria-label of the hamburger shown below md.
closeMenu
Close menu
Đóng menu
Accessible name of the off-canvas sheet’s close button. MobileMenu passes it to SheetContent as closeLabel, over that primitive’s generic “Close” from the sheet namespace — heard out of context, the button has to say what it closes.
search
Search
Tìm kiếm
aria-label of the compact search icon button below lg.
searchPlaceholder
Search…
Tìm kiếm…
Visible text inside the header’s search pill.
notifications
Notifications
Thông báo
aria-label of NotificationsButton; the unread count is appended in parentheses.
navigationLabel
Main navigation
Điều hướng chính
Accessible name of the sidebar <nav>, and the sr-only title of the mobile sheet.
skipToContent
Skip to main content
Bỏ qua điều hướng, sang nội dung chính
The skip link at the top of the frame (new in 3.7.0) — hidden until it takes focus, then visible text and accessible name at once.
Workspace switcher — axis 1
workspace
Workspace
Không gian làm việc
aria-label of the workspace list inside the popover.
workspaceSwitcherLabel
Switch workspace
Đổi không gian làm việc
aria-label of the trigger, both expanded and in the icon rail.
workspaceSearchPlaceholder
Search workspaces…
Tìm không gian làm việc…
Placeholder and aria-label of the search box — one string doing both jobs.
workspaceEmpty
No workspaces found
Không tìm thấy không gian làm việc
Shown when the search matches nothing.
createWorkspace
Create workspace
Tạo không gian làm việc
Footer row, rendered only when onCreateWorkspace is given.
workspaceSettings
Workspace settings
Cài đặt không gian làm việc
Footer row, rendered only when onWorkspaceSettings is given.
inviteMembers
Invite members
Mời thành viên
Footer row, rendered only when onInviteMembers is given.
members
1 member · 24 members
1 thành viên · 24 thành viên
The whole member-count phrase built from Workspace.memberCount. A function, not a string: it interpolates the number, word order around it changes by language, and English has to choose between “member” and “members”.
App launcher — axis 2
apps
Apps
Ứng dụng
Tooltip on the app launcher button in the header.
appLauncherLabel
Open apps list
Mở danh sách ứng dụng
aria-label of that button.
myApps
Your apps
Ứng dụng của bạn
Heading over the entitled apps.
discoverApps
Discover more
Khám phá thêm
Heading over the locked ones.
locked
Locked
Chưa mở khoá
Appended to a locked tile’s accessible name — “CRM — Locked”.
lockedHint
Not included in your current plan
Ứng dụng chưa có trong gói hiện tại
The title attribute on a locked tile.
User menu
account
Account
Tài khoản
aria-label of the avatar trigger.
profile
Profile
Hồ sơ
Row label, rendered only when profileHref is set.
settings
Settings
Cài đặt
Row label, rendered only when settingsHref is set.
appearance
Appearance
Giao diện
The theme submenu’s trigger.
help
Help
Trợ giúp
Row label, rendered only when helpHref is set.
logout
Log out
Đăng xuất
Sign out, in both the user menu and the workspace switcher.
Theme
themeLight
Light
Sáng
Light option, and half of the toggle’s tooltip.
themeDark
Dark
Tối
Dark option, and the other half.
themeSystem
System
Hệ thống
Follow the OS.
toggleTheme
Toggle theme
Đổi giao diện
aria-label of ThemeToggle. ThemeToggleLabels is exactly these four keys, for use outside the shell.
DEFAULT_SHELL_LABELS still lives in a plain module — types and one constant, no hooks, no 'use client' — but since 3.0.0 it is only the English table, kept for an app to spread; nothing in the shell reads it. For a string rendered on the server in the user’s language, getComitorMessages(locale).shell is the pure equivalent. What a Server Component cannot do is hand a whole table to AppShell: members is a function, and functions do not cross the server → client boundary. A labels object that carries one is built inside a client component.
AppShell props
AppShellProps extends ShellProviderProps, so everything in the next table is accepted here too and forwarded untouched. These are the props AppShell keeps for itself.
Prop
Type
Default
Description
layout
'sidebar-first' | 'header-first'
'sidebar-first'
Which of the two arrangements to build. sidebar-first runs the sidebar the full height with the header beside it; header-first puts the header across the top and moves the workspace switcher into it.
showSidebar
boolean
true
Render the desktop sidebar at all. false drops the <aside> entirely — for a single-area product that has nothing to put in a rail. The header, the app launcher and the off-canvas MobileMenu stay.
railHeader
'module' | 'brand'
'module'
New in 3.17.0. Only with moduleLayout="rail" in sidebar-first. 'module' (the 3.16.0 look): the logo tops the module rail and the open module’s name tops the sidebar. 'brand': ONE strip — logo, name, subtitle, brand.trailing, or the workspace switcher — runs across both columns, exactly like the top of an ordinary sidebar; the rail drops its logo and the sidebar its module title. See /shell/layouts.
railBrandCollapsed
'mark' | 'name'
'mark'
New in 3.17.0. With railHeader="brand" and the sidebar collapsed, what the 137px strip keeps beside the logo: nothing ('mark' — the name moves to a tooltip) or the app’s name on up to two lines ('name' — for short names, about 70px per line at the default font size). The logo keeps its size and position either way.
headerProps
AppHeaderProps
—
Forwarded to <AppHeader />: title, breadcrumb, logo, showModuleTabs (3.16.0), searchSlot, showSearch, showSidebarToggle, notificationsSlot, notificationCount, onNotificationsClick, extraSlot, showAppLauncher, showThemeToggle, showUserMenu, userMenuProps, className. Spread before the layout-derived defaults, so any key you set wins and any key you leave out falls back.
sidebarProps
SidebarProps
—
Forwarded to <Sidebar />: header, footer, showWorkspaceSwitcher, showCollapseButton, className. The last two default to false in header-first, where the header has taken both over. showModuleTitle (3.17.0) is set by AppShell from railHeader and ignored here — it is for frames you assemble yourself.
mobileMenuProps
MobileMenuProps
—
Forwarded to <MobileMenu />: header, footer, showAppLauncher, showThemeToggle, userMenuProps, moduleChips, className. The off-canvas sheet is mounted for you in both layouts. moduleChips (3.17.0): 'link' (default) makes each module chip a link to its landing page that closes the sheet; 'picker' makes it a button that shows that module’s menu without navigating — the sheet stays open until a screen is picked.
commandPaletteSlot
ReactNode
—
Where your own command palette is rendered. The shell owns only the open/close state (⌘/Ctrl+K, useShell().commandPaletteOpen) — what is searchable is the app’s data, so nothing is shipped. Leave it out and the shortcut toggles a boolean nothing reads.
locale
string
—
A shortcut (new in 3.0.0), not the recommended place. Set to a BCP 47 code — vi, en, en-GB — and AppShell wraps its own tree in a ComitorLocaleProvider with that locale; left out, the frame reads whichever provider is above it, English when there is none. Prefer the provider in the root layout: sign-in and error pages never render the shell. The provider built here is a new one and does not inherit messages or dateFnsLocale from an outer provider. Switching between set and unset changes the tree’s shape and remounts the whole shell, so keep it always present or always absent.
skipLink
boolean
true
New in 3.7.0. A “Skip to main content” link as the frame’s first focusable element (WCAG 2.4.1, level A): sr-only until it takes focus, worded by labels.skipToContent. Turn it off only if your page already draws one — two in a row is worse than one.
mainId
string
"main-content"
New in 3.7.0. The id of the <main> — the skip link’s target. The <main> also carries tabIndex={-1}, so the jump moves focus rather than only scrolling, and the next Tab lands in the page instead of back at the top of the sidebar. Change it only when two frames share a document (as on this site) or the id is taken.
className
string
—
Classes on the outer frame. It is h-dvh w-full overflow-hidden by default and merged through tailwind-merge, so h-full here really does replace the viewport height — which is how the demo above fits inside a box.
contentClassName
string
—
Classes on the <main>. It ships relative flex-1 overflow-y-auto bg-background; add padding or a max width here, but do not remove relative (see “Why the main element is positioned” below).
childrenrequired
ReactNode
—
The page. Rendered inside the scrolling <main>. The document itself never scrolls — the outer frame is h-dvh overflow-hidden — but the sidebar’s own <nav> scrolls separately, so a long menu never drags your page with it.
ShellProvider props
The complete data contract of Tier 3. Nothing here is stored by the package — every workspace, product, menu and user is yours, passed in.
Prop
Type
Default
Description
brand
ShellBrand
—
New in 3.15.0. The app’s own logo and name — { name, logo?, logoUrl?, subtitle?, href? } — for an app outside the workspace × app model. With no workspaces, AppBrand stands where the WorkspaceSwitcher would: top of the sidebar in sidebar-first, start of the header in header-first, top of the mobile menu. With workspaces present the switcher wins and brand is not drawn.
workspaces
Workspace[]
[]
Axis 1. Fills the WorkspaceSwitcher, and supplies the key the sidebar collapse state is remembered under. An empty array removes the switcher strip rather than leaving an empty band — or, when brand is set, puts AppBrand in it.
currentWorkspaceId
string
—
Which workspace is open. An id that matches nothing — and an omitted id — falls back to workspaces[0], so currentWorkspace is null only for an empty list.
onWorkspaceChange
(workspaceId: string) => void
—
A different workspace was picked. The shell does not navigate; you decide what changing the data context means.
onCreateWorkspace
() => void
—
The “Create workspace” row. Omit it and the row is not rendered — no dead entries.
onWorkspaceSettings
(workspaceId: string) => void
—
The “Workspace settings” row, same rule.
onInviteMembers
(workspaceId: string) => void
—
The “Invite members” row, same rule.
apps
AppDescriptor[]
[]
Axis 2 — every product of the ecosystem, including the ones this workspace has not bought. entitled: false renders a padlock and routes the click to onUpsell instead. An empty array renders no launcher at all.
currentAppId
string
—
Which product is open. Left out, the shell derives it with resolveCurrentApp(apps, pathname) — longest path prefix wins, falling back to the first app. Set explicitly to an id that matches nothing, currentApp is null and no fallback applies.
onAppSelect
(app: AppDescriptor) => void
—
An entitled app was chosen, from the launcher or from Alt+1…9. Without it the launcher tile renders as a real link through LinkComponent.
onUpsell
(appId: string) => void
—
A locked app was chosen. Send the user to billing; the shell will not navigate to a product they cannot open.
modules
ShellModule[]
[]
New in 3.16.0. The business areas of ONE app — Production, HR, Commerce — each { id, label, shortLabel?, icon, nav, href?, badge?, badgeVariant? }. With modules set, nav holds only the shared items (Overview, Settings…). Not the same axis as apps: a module is an internal link of the open app, an app is another domain. Fewer than two visible modules and every moduleLayout falls back to a plain sidebar; a module with nothing openable is dropped.
moduleLayout
'grouped' | 'rail' | 'switcher' | 'tabs'
'grouped'
New in 3.16.0. How the modules are laid out on wide screens: one sidebar with a collapsible group per module · an 84px module rail (72px before 3.17.1) beside a 240px sidebar of the module on display · a module picker under the top of the sidebar · one tab per module in the header. Below md the last three share a row of module chips at the top of the mobile menu. Live comparison at /shell/layouts.
currentModuleId
string
—
New in 3.16.0. Forces the open module. Left out — the normal case — it is DERIVED: the first module (in the order passed) holding an active item. On a shared page it is null, and the sidebar keeps the menu of the module just left.
onModuleSelect
(module: ShellModule) => void
—
New in 3.16.0. Only for Alt+1…9: with two or more modules AND this callback, the keys pick the nth module instead of the nth app. The rail tiles, the picker rows and the tabs are already real links, so they do not need it. Usually router.push(moduleLandingHref(module)).
nav
NavItem[] | NavGroup[]
—
The menu of the current app. A flat array is normalised into one unlabelled group by toNavGroups(), so useShell().nav is always NavGroup[].
pathname
string
usePathname() ?? ""
Route used for active matching and for resolving the current app. Leave it out on Next; pass it in Storybook, in tests, or in any non-Next app, where usePathname() returns null.
Only needed when two rows differ by query alone (/settings?tab=general vs ?tab=members). Accepts Next’s useSearchParams(), a "?tab=x" string, or a Server Component searchParams object. The shell never calls useSearchParams() itself: that would push a Suspense boundary and client rendering onto every page that imported the shell.
defaultSidebarCollapsed
boolean
false
The collapse state the server renders. After mount the stored per-workspace choice wins.
collapseSidebarOnTablet
boolean
true
Between 768px and 1023px, start collapsed when the user has never toggled — a 256px rail eats a third of that width. Read once on mount, never on resize, and never written back to storage: it is a default, not a decision.
persistSidebarState
boolean
true
Remember the collapse state in localStorage under comitor-sidebar:<workspaceId>. Turn it off for Storybook, kiosk mode, or a docs page like this one.
enableShortcuts
boolean
true
Bind the global key handler on window: ⌘/Ctrl+K, ⌘/Ctrl+Shift+K, ⌘/Ctrl+B, Alt+1…9 and Escape. Off means no listener is registered at all.
LinkComponent
ComponentType<ShellLinkProps>
next/link
The link every navigable part of the shell renders through. Swap it for your router outside Next; href is typed as a plain string so any router can be adapted.
onNavigate
(href: string) => void
—
Fired by ShellNavLink after each internal click that was not defaultPrevented — analytics, or closing something of your own. The mobile menu and the launcher already close themselves.
user
ShellUser | null
null
The signed-in user. null hides the UserMenu completely — the header renders no avatar and no separator.
onLogout
() => void
—
Sign out. Reachable from both the user menu and the workspace switcher’s footer.
labels
Partial<ShellLabels>
—
Wording of your own, merged key by key over the shell table of the nearest ComitorLocaleProvider — English when there is none, Vietnamese under locale vi. Includes strings only a screen reader hears. Not the way to choose a language: that is the provider’s job, once, at the root.
childrenrequired
ReactNode
—
Everything that may call useShell(). ShellProvider also wraps it in a TooltipProvider with a 200ms delay.
useShell() return value
ShellContextValue, memoised on its inputs. Callbacks are passed through as-is, so an undefined handler is information: several parts of the shell drop a row rather than render one that does nothing.
Prop
Type
Default
Description
brand
ShellBrand | null
—
New in 3.15.0. The brand as passed, or null when the app passed none — what AppBrand reads.
workspaces
Workspace[]
—
Axis 1, exactly as passed.
currentWorkspace
Workspace | null
—
The match for currentWorkspaceId, else the first workspace, else null.
apps
AppDescriptor[]
—
Axis 2, entitled and locked together. Filter with getEntitledApps / getLockedApps.
currentApp
AppDescriptor | null
—
The match for currentAppId, or resolveCurrentApp(apps, pathname) when no id was given.
nav
NavGroup[]
—
Always normalised — a flat NavItem[] has already become one unlabelled group.
sidebarNav · globalNav
NavGroup[]
—
New in 3.16.0. What the sidebar actually draws (the shared items plus the module on display, or everything in grouped) and the shared items alone. nav stays EVERY item — one labelled group per module, id module:<id> — so a ⌘K palette reading nav still finds every screen; do not switch it to sidebarNav.
New in 3.16.0. The visible modules, the module of the page that is open (null on a shared page), the module whose menu the sidebar shows (the one just left, on a shared page), and the layout in effect — null when fewer than two modules are visible.
pathname
string
—
The route the shell is matching against. Never null: usePathname()’s null becomes "".
searchParams
URLSearchParams | null
—
The normalised query, or null when the app did not pass one. null is not the same as empty: empty means “this URL has no query”, null means “the shell was not told”, and active matching treats the two differently.
LinkComponent
ShellLinkComponent
—
The link component in force — next/link unless you replaced it.
sidebarCollapsed
boolean
—
Current rail state.
setSidebarCollapsed
(collapsed: boolean) => void
—
Set it and write it through to localStorage (when persistSidebarState is on).
toggleSidebar
() => void
—
Flip it and persist, from a functional update — safe to call from a handler that does not know the current value.
mobileMenuOpen · setMobileMenuOpen
boolean · (open: boolean) => void
—
The off-canvas sheet below md.
commandPaletteOpen · setCommandPaletteOpen
boolean · (open: boolean) => void
—
The ⌘/Ctrl+K boolean. Nothing renders from it unless you supply commandPaletteSlot.
appLauncherOpen · setAppLauncherOpen
boolean · (open: boolean) => void
—
The app grid popover — three columns of tiles — also on ⌘/Ctrl+Shift+K.
labels
ShellLabels
—
The fully merged strings — never Partial. Read from here rather than re-deriving the merge.
The callbacks, passed straight through. undefined is meaningful: several parts of the shell hide a row rather than render one that does nothing.
user
ShellUser | null
—
The signed-in user, or null.
ShellBrand fields
The shape of brand (3.15.0). Only name is required.
Prop
Type
Default
Description
namerequired
string
—
The app’s name, shown beside the logo and used as the home link’s accessible name — and the tooltip when the sidebar is collapsed. Pass it in the user’s language.
logo
ReactNode
—
An element — an inline SVG, an <Image>. Drawn in a 32px box (24px collapsed) and not recoloured by the shell.
logoUrl
string
—
An image URL, used when there is no logo.
subtitle
string
—
A second, smaller line under the name — a company name, an environment. Hidden when collapsed.
wordmark
ReactNode
—
New in 3.17.0. A logo WITH lettering — mark and name drawn as one image. Replaces the square logo + name + subtitle at the full and name levels, inside an @container (hide the extras with a max-width container variant when it is narrow). aria-hidden; name stays the accessible name. logo / logoUrl still serve the collapsed mark.
trailing
ReactNode
—
New in 3.17.0. Something to the RIGHT of the name — a version, an environment tag. Shown wherever AppBrand stands, hidden when collapsed, never recoloured: style secondary text text-xs text-muted-foreground.
href
string
"/"
Where the logo and name link to.
AppBrand props
Everything else comes from useShell().brand, so it must render inside ShellProvider or AppShell.
Prop
Type
Default
Description
collapsed
boolean
false
Logo only, in a 36px square, with the name in a tooltip and the aria-label — the collapsed-sidebar form. Pass useShell().sidebarCollapsed when you place it in the sidebar yourself.
detail
'full' | 'name' | 'mark'
'full'
New in 3.17.0. How much to show while keeping the 32px logo box and its padding: logo + name + subtitle + trailing; logo + name on up to two lines; or the logo alone (name in tooltip and aria-label). For a narrow spot that still fits a full-size logo — collapsed centres a 24px box, which makes the logo jump.
className
string
—
Merged onto the link.
Accessibility
One landmark per job. The frame emits a <header>, a <nav> named by labels.navigationLabel and a single <main> holding your page. Do not nest another <main> in children.
Escape always works. It is the one shortcut evaluated before the typing guard, so it closes the command palette, the launcher and the mobile menu even while a text field has focus — including the search field inside the thing being closed.
Nothing is claimed that is not implemented.⌘/Ctrl+K flips a boolean and no more; without a commandPaletteSlot there is no dialog to announce, and no focus to trap. If you advertise the shortcut in your UI, supply the slot.
Alt+digits use event.code. On macOS Alt+1 produces “¡”, so matching on event.key would silently fail for every Mac user. Locked apps are skipped, so the numbers count what a person can actually open.
A skip link comes with the frame. Since 3.7.0 the first thing Tab reaches is “Skip to main content” — invisible until focused — which jumps past the whole sidebar to <main id="main-content" tabIndex={-1}>. The tabIndex is what makes the jump move focus: without it the browser scrolls but focus stays on the link, and the next Tab goes back to the top of the sidebar. An app that drew its own skip link beside the shell should delete it — and its own target — or keyboard users meet two in a row. Rename the target with mainId, drop the link with skipLink={false}, reword it with skipToContent.
The language reaches the invisible strings too.ShellLabels covers aria-label and sr-only text as well as visible copy, and the provider switches all of it at once. An app that rewords only the visible half leaves screen reader users on different words from everyone else — and where the two describe one control, an accessible name in one language over visible text in another breaks WCAG 2.5.3 outright.
The theme lives above the shell. Mounting ThemeProvider inside AppShell makes the page flash the wrong palette during hydration — a real problem for readers sensitive to sudden luminance change, and the reason the display axes belong at the root layout.
position: relative on the main region is an a11y fix too. Without it the visually hidden labels that make the frame readable are exactly what stretches the document and produces the phantom blank scroll.
The collapsed rail keeps its names. Every icon tile carries a tooltip and an aria-label, and the active row is marked aria-current="page" at both widths — see Sidebar for the measurements behind those choices.