Shell
App Shell
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.
Axis 2
App — the product
Tasks ▸ Chat ▸ CRM, inside one workspace. Fed by apps and currentAppId, rendered by AppLauncher.
A third thing, often confused with these: the four display axes — light/dark, colour palette, layout density and font size. 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.
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.)
Rendered as children, inside the scrolling main
- Workspace
- Acme Corp · Business
- App
- Tasks
- pathname
- /tasks/inbox
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 toonUpsell— 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
showSidebaroff. 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
ThemeTogglethat talks to whicheverThemeProvideris 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 sameMobileMenuthe frame already mounted for you.
What the frame mounts
ShellProvider— every prop you pass that is not in the table ofAppShell’s own props is forwarded here verbatim. It also wraps the tree in aTooltipProviderwith a 200ms delay, which is what the collapsed icon rail needs.Sidebar— the desktop rail, hidden belowmdby CSS. Configure it throughsidebarProps; it is documented in full at Sidebar.AppHeader— 56px, three regions, with the search pill in the middle one. Configure it throughheaderProps; see App Header.<main>— yourchildren, in the frame’s page-level scroll container. The outer element ish-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 bymobileMenuOpen. Its content order deliberately differs from the desktop rail; see Navigation Model.--app-accentfor the whole tree — the frame readscurrentAppout 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 thatAppShellitself renders.
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.
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.
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>
)
}Where it goes in a Next app
Two layouts, two jobs. The root layout owns the display axes — they must sit above everything so the page does not flash the wrong theme during hydration. 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 — the four display axes belong at the ROOT, outside AppShell.
import type { ReactNode } from 'react'
import { ContrastProvider, DensityProvider, FontSizeProvider, ThemeProvider } from '@comitor/ui/shell'
import './globals.css' // @import "tailwindcss"; @import "@comitor/ui/styles.css";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
// Required: all four anti-flash scripts touch <html> before React hydrates,
// so the client markup never matches the server markup here.
<html lang="vi" suppressHydrationWarning>
<body>
{/* The four axes know nothing about each other, 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. */}
<ThemeProvider>
<ContrastProvider>
<DensityProvider>
<FontSizeProvider>{children}</FontSizeProvider>
</DensityProvider>
</ContrastProvider>
</ThemeProvider>
</body>
</html>
)
}// 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}
>
{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.
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.
'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.
'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 | Matched on event.code, not event.key: on macOS Alt+1 produces “¡”. Locked apps are skipped — the index counts getEntitledApps(apps). |
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 iscomitor-sidebar:<workspaceId>(comitor-sidebar:defaultwhen 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_statecookie; adopting that here would put a second source of truth next toShellProvider, and a single cookie is the wrong shape for a preference that varies per workspace. It is also why@comitor/uiships no shadcn sidebar at all — see Sidebar. - Read after mount, never during render. The first client render uses exactly the
defaultSidebarCollapsedthe 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.breakpointsin@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.
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, and the shipped defaults in DEFAULT_SHELL_LABELS are Vietnamese — including the strings only a screen reader ever hears. Pass labels to AppShell or ShellProvider once and every descendant picks it up from context; there is nothing to fork and nothing to wrap.
import type { ReactNode } from 'react'
import { AppShell, DEFAULT_SHELL_LABELS } from '@comitor/ui/shell'
import type { ShellLabels } from '@comitor/ui/shell'
import { NAV } from './shell-model'
// Partial<ShellLabels>: the provider always spreads { ...DEFAULT_SHELL_LABELS,
// ...labels }, so overriding four keys keeps the other twenty-nine.
export const EN_SHELL: Partial<ShellLabels> = {
navigationLabel: 'Main navigation',
collapseSidebar: 'Collapse sidebar',
// 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 sidebar',
}
export function Shell({ children }: { children: ReactNode }) {
return (
<AppShell labels={EN_SHELL} nav={NAV}>
{children}
</AppShell>
)
}
// Reading a default back — for a tooltip of your own, say — never hard-code the
// string; take it from the constant so it tracks the package.
export const fallbackNotifications = DEFAULT_SHELL_LABELS.notifications // 'Thông báo'There are 33 keys in 1.0.0, the same set as 0.9.1 — the README’s Tier 3 table still says 32, from before collapseSidebarShort was split out of collapseSidebar. Two rules for 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).
| Key | Default (vi) | What it names |
|---|---|---|
| Sidebar & header | ||
| collapseSidebar | Thu gọn thanh bên | aria-label and tooltip of the collapse button, in the sidebar footer and in the header toggle. |
| collapseSidebarShort | Thu gọn | The visible text on that same button. Must remain a substring of collapseSidebar. |
| expandSidebar | Mở rộng thanh bên | The same control once the rail is collapsed. |
| openMenu | Mở menu | aria-label of the hamburger shown below md. |
| closeMenu | Đóng menu | Accessible name of the off-canvas sheet’s close button — MobileMenu passes it to SheetContent as closeLabel, overriding that primitive’s hardcoded Vietnamese default. |
| search | Tìm kiếm | aria-label of the compact search icon button below lg. |
| searchPlaceholder | Tìm kiếm… | Visible text inside the header’s search pill. |
| notifications | Thông báo | aria-label of NotificationsButton; the unread count is appended in parentheses. |
| navigationLabel | Điều hướng chính | Accessible name of the sidebar <nav>, and the sr-only title of the mobile sheet. |
| Workspace switcher — axis 1 | ||
| workspace | Không gian làm việc | aria-label of the workspace list inside the popover. |
| workspaceSwitcherLabel | Đổi không gian làm việc | aria-label of the trigger, both expanded and in the icon rail. |
| workspaceSearchPlaceholder | Tìm không gian làm việc… | Placeholder and aria-label of the search box — one string doing both jobs. |
| workspaceEmpty | Không tìm thấy không gian làm việc | Shown when the search matches nothing. |
| createWorkspace | Tạo không gian làm việc | Footer row, rendered only when onCreateWorkspace is given. |
| workspaceSettings | Cài đặt không gian làm việc | Footer row, rendered only when onWorkspaceSettings is given. |
| inviteMembers | Mời thành viên | Footer row, rendered only when onInviteMembers is given. |
| members | (count) => `${count} thành viên` | The whole member-count phrase built from Workspace.memberCount — “24 thành viên”. A function, not a string: it interpolates the number, and word order around it changes by language. |
| App launcher — axis 2 | ||
| apps | Ứng dụng | Tooltip on the app launcher button in the header. |
| appLauncherLabel | Mở danh sách ứng dụng | aria-label of that button. |
| myApps | Ứng dụng của bạn | Heading over the entitled apps. |
| discoverApps | Khám phá thêm | Heading over the locked ones. |
| locked | Chưa mở khoá | Appended to a locked tile’s accessible name — “CRM — Locked”. |
| lockedHint | Ứng dụng chưa có trong gói hiện tại | The title attribute on a locked tile. |
| User menu | ||
| account | Tài khoản | aria-label of the avatar trigger. |
| profile | Hồ sơ | Row label, rendered only when profileHref is set. |
| settings | Cài đặt | Row label, rendered only when settingsHref is set. |
| appearance | Giao diện | The theme submenu’s trigger. |
| help | Trợ giúp | Row label, rendered only when helpHref is set. |
| logout | Đăng xuất | Sign out, in both the user menu and the workspace switcher. |
| Theme | ||
| themeLight | Sáng | Light option, and half of the toggle’s tooltip. |
| themeDark | Tối | Dark option, and the other half. |
| themeSystem | Hệ thống | Follow the OS. |
| toggleTheme | Đổi giao diện | aria-label of ThemeToggle. ThemeToggleLabels is exactly these four keys, for use outside the shell. |
DEFAULT_SHELL_LABELS lives in a plain module — types and one constant, no hooks, no 'use client' — so a Server Component can import it to build a translated object without pulling the shell in.
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. |
headerProps | AppHeaderProps | — | Forwarded to <AppHeader />: title, breadcrumb, logo, 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. |
mobileMenuProps | MobileMenuProps | — | Forwarded to <MobileMenu />: header, footer, showAppLauncher, showThemeToggle, userMenuProps, className. The off-canvas sheet is mounted for you in both layouts. |
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. |
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 |
|---|---|---|---|
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. |
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. |
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. |
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. |
searchParams | URLSearchParams | string | Record<string, string | string[] | undefined> | 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> | DEFAULT_SHELL_LABELS | Display strings, merged key by key over the Vietnamese defaults. Includes strings only a screen reader hears. |
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 |
|---|---|---|---|
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. |
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. |
onWorkspaceChange · onCreateWorkspace · onWorkspaceSettings · onInviteMembers · onAppSelect · onUpsell · onNavigate · onLogout | (…) => void | undefined | — | 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. |
Accessibility
- One landmark per job. The frame emits a
<header>, a<nav>named bylabels.navigationLabeland a single<main>holding your page. Do not nest another<main>inchildren. - 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+Kflips a boolean and no more; without acommandPaletteSlotthere is no dialog to announce, and no focus to trap. If you advertise the shortcut in your UI, supply the slot. Alt+digits useevent.code. On macOSAlt+1produces “¡”, so matching onevent.keywould silently fail for every Mac user. Locked apps are skipped, so the numbers count what a person can actually open.- Vietnamese defaults, including invisible strings.
ShellLabelscoversaria-labelandsr-onlytext as well as visible copy. An app that translates only the visible half leaves screen reader users on a different language from everyone else — and where the two describe one control, an English accessible name over Vietnamese visible text breaks WCAG 2.5.3 outright. - The theme lives above the shell. Mounting
ThemeProviderinsideAppShellmakes 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: relativeon 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 markedaria-current="page"at both widths — see Sidebar for the measurements behind those choices.
Related
Shell overview
The tier as a whole: which piece goes where, and what belongs in the root layout.
App Header
Everything headerProps forwards — the three regions, the search pill, NotificationsButton.
Sidebar
Everything sidebarProps forwards — the rail, the collapse behaviour, the group and item chrome.
Navigation Model
The NavItem / NavGroup data you hand to nav, and the matchers that decide the active row.
User Menu
The right end of the header: UserMenu, UserAvatar, and the ShellUser contract.
Display Axes
Theme, contrast, density and font size — four independent axes that sit above the shell.
@comitor/ui/shellPeer dependencies: next and next-themes.