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.

Workspace Switcher

Axis 2

App — the product

Tasks ▸ Chat ▸ CRM, inside one workspace. Fed by apps and currentAppId, rendered by AppLauncher.

App Launcher

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.)

Layout

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 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.

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.

Defaultsidebar-firstheader-first
headerProps.showSidebarTogglefalsetrue
headerProps.logoundefined<WorkspaceSwitcher />
sidebarProps.showWorkspaceSwitchertruefalse
sidebarProps.showCollapseButtontruefalse

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>
  )
}

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
// 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
// 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.

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.

KeysDoesDetail
EscClose the command palette, the mobile menu and the app launcherHandled BEFORE the typing guard — the only shortcut that still fires while an input has focus.
⌘/Ctrl + KToggle commandPaletteOpenTested after ⌘/Ctrl+Shift+K, since both are the “k” key. Nothing appears unless commandPaletteSlot is supplied.
⌘/Ctrl + Shift + KToggle appLauncherOpenChecked first, so the Shift variant is never swallowed by the plain one.
⌘/Ctrl + BCollapse or expand the sidebarGoes through toggleSidebar, so the new state is persisted like a click.
Alt + 1…9Jump to the nth entitled appMatched 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 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, 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.

tsx
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).

KeyDefault (vi)What it names
Sidebar & header
collapseSidebarThu gọn thanh bênaria-label and tooltip of the collapse button, in the sidebar footer and in the header toggle.
collapseSidebarShortThu gọnThe visible text on that same button. Must remain a substring of collapseSidebar.
expandSidebarMở rộng thanh bênThe same control once the rail is collapsed.
openMenuMở menuaria-label of the hamburger shown below md.
closeMenuĐóng menuAccessible name of the off-canvas sheet’s close button — MobileMenu passes it to SheetContent as closeLabel, overriding that primitive’s hardcoded Vietnamese default.
searchTìm kiếmaria-label of the compact search icon button below lg.
searchPlaceholderTìm kiếm…Visible text inside the header’s search pill.
notificationsThông báoaria-label of NotificationsButton; the unread count is appended in parentheses.
navigationLabelĐiều hướng chínhAccessible name of the sidebar <nav>, and the sr-only title of the mobile sheet.
Workspace switcher — axis 1
workspaceKhông gian làm việcaria-label of the workspace list inside the popover.
workspaceSwitcherLabelĐổi không gian làm việcaria-label of the trigger, both expanded and in the icon rail.
workspaceSearchPlaceholderTìm không gian làm việc…Placeholder and aria-label of the search box — one string doing both jobs.
workspaceEmptyKhông tìm thấy không gian làm việcShown when the search matches nothing.
createWorkspaceTạo không gian làm việcFooter row, rendered only when onCreateWorkspace is given.
workspaceSettingsCài đặt không gian làm việcFooter row, rendered only when onWorkspaceSettings is given.
inviteMembersMời thành viênFooter 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ụngTooltip on the app launcher button in the header.
appLauncherLabelMở danh sách ứng dụngaria-label of that button.
myAppsỨng dụng của bạnHeading over the entitled apps.
discoverAppsKhám phá thêmHeading over the locked ones.
lockedChư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ạiThe title attribute on a locked tile.
User menu
accountTài khoảnaria-label of the avatar trigger.
profileHồ sơRow label, rendered only when profileHref is set.
settingsCài đặtRow label, rendered only when settingsHref is set.
appearanceGiao diệnThe theme submenu’s trigger.
helpTrợ giúpRow label, rendered only when helpHref is set.
logoutĐăng xuấtSign out, in both the user menu and the workspace switcher.
Theme
themeLightSángLight option, and half of the toggle’s tooltip.
themeDarkTốiDark option, and the other half.
themeSystemHệ thốngFollow the OS.
toggleThemeĐổi giao diệnaria-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.

PropTypeDefaultDescription
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.
showSidebarbooleantrueRender 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.
headerPropsAppHeaderPropsForwarded 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.
sidebarPropsSidebarPropsForwarded to <Sidebar />: header, footer, showWorkspaceSwitcher, showCollapseButton, className. The last two default to false in header-first, where the header has taken both over.
mobileMenuPropsMobileMenuPropsForwarded to <MobileMenu />: header, footer, showAppLauncher, showThemeToggle, userMenuProps, className. The off-canvas sheet is mounted for you in both layouts.
commandPaletteSlotReactNodeWhere 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.
classNamestringClasses 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.
contentClassNamestringClasses 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).
childrenrequiredReactNodeThe 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.

PropTypeDefaultDescription
workspacesWorkspace[][]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.
currentWorkspaceIdstringWhich 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) => voidA different workspace was picked. The shell does not navigate; you decide what changing the data context means.
onCreateWorkspace() => voidThe “Create workspace” row. Omit it and the row is not rendered — no dead entries.
onWorkspaceSettings(workspaceId: string) => voidThe “Workspace settings” row, same rule.
onInviteMembers(workspaceId: string) => voidThe “Invite members” row, same rule.
appsAppDescriptor[][]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.
currentAppIdstringWhich 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) => voidAn 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) => voidA locked app was chosen. Send the user to billing; the shell will not navigate to a product they cannot open.
navNavItem[] | NavGroup[]The menu of the current app. A flat array is normalised into one unlabelled group by toNavGroups(), so useShell().nav is always NavGroup[].
pathnamestringusePathname() ?? ""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.
searchParamsURLSearchParams | string | Record<string, string | string[] | undefined> | nullOnly 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.
defaultSidebarCollapsedbooleanfalseThe collapse state the server renders. After mount the stored per-workspace choice wins.
collapseSidebarOnTabletbooleantrueBetween 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.
persistSidebarStatebooleantrueRemember the collapse state in localStorage under comitor-sidebar:<workspaceId>. Turn it off for Storybook, kiosk mode, or a docs page like this one.
enableShortcutsbooleantrueBind 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.
LinkComponentComponentType<ShellLinkProps>next/linkThe 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) => voidFired 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.
userShellUser | nullnullThe signed-in user. null hides the UserMenu completely — the header renders no avatar and no separator.
onLogout() => voidSign out. Reachable from both the user menu and the workspace switcher’s footer.
labelsPartial<ShellLabels>DEFAULT_SHELL_LABELSDisplay strings, merged key by key over the Vietnamese defaults. Includes strings only a screen reader hears.
childrenrequiredReactNodeEverything 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.

PropTypeDefaultDescription
workspacesWorkspace[]Axis 1, exactly as passed.
currentWorkspaceWorkspace | nullThe match for currentWorkspaceId, else the first workspace, else null.
appsAppDescriptor[]Axis 2, entitled and locked together. Filter with getEntitledApps / getLockedApps.
currentAppAppDescriptor | nullThe match for currentAppId, or resolveCurrentApp(apps, pathname) when no id was given.
navNavGroup[]Always normalised — a flat NavItem[] has already become one unlabelled group.
pathnamestringThe route the shell is matching against. Never null: usePathname()’s null becomes "".
searchParamsURLSearchParams | nullThe 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.
LinkComponentShellLinkComponentThe link component in force — next/link unless you replaced it.
sidebarCollapsedbooleanCurrent rail state.
setSidebarCollapsed(collapsed: boolean) => voidSet it and write it through to localStorage (when persistSidebarState is on).
toggleSidebar() => voidFlip it and persist, from a functional update — safe to call from a handler that does not know the current value.
mobileMenuOpen · setMobileMenuOpenboolean · (open: boolean) => voidThe off-canvas sheet below md.
commandPaletteOpen · setCommandPaletteOpenboolean · (open: boolean) => voidThe ⌘/Ctrl+K boolean. Nothing renders from it unless you supply commandPaletteSlot.
appLauncherOpen · setAppLauncherOpenboolean · (open: boolean) => voidThe app grid popover — three columns of tiles — also on ⌘/Ctrl+Shift+K.
labelsShellLabelsThe fully merged strings — never Partial. Read from here rather than re-deriving the merge.
onWorkspaceChange · onCreateWorkspace · onWorkspaceSettings · onInviteMembers · onAppSelect · onUpsell · onNavigate · onLogout(…) => void | undefinedThe callbacks, passed straight through. undefined is meaningful: several parts of the shell hide a row rather than render one that does nothing.
userShellUser | nullThe signed-in user, or null.

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.
  • Vietnamese defaults, including invisible strings. ShellLabels covers aria-label and sr-only text 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 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.

Related

@comitor/ui/shellPeer dependencies: next and next-themes.