Shell

Shell

Tier 3 is the frame a Comitor product runs inside: sidebar, header, workspace switcher, app launcher, user menu, off-canvas menu — one AppShell that takes the app’s data once and hands it to every part through context.

It has an entry point of its own — @comitor/ui/shell — and every page in this section imports from there.

Why it lives behind its own entry point

The package is built with bundle: false — one ESM file per source module. That makes a barrel a hard boundary: if the main barrel re-exported even one file that imports next, every app using @comitor/ui would have to install Next, or break at resolve time. So the frame — the only tier that touches next/link, next/navigation and next-themes — is exported separately, and both peers are declared optional.

The practical effect: a Vite app, a Storybook, a Vitest run can use every primitive and every composite with no Next in the tree at all. Only the apps that ask for a frame pay for one.

terminal
# Tier 1 + Tier 2 need none of this — @comitor/ui alone runs on Vite, Storybook, Vitest.
pnpm add @comitor/ui

# Tier 3 pulls the two optional peers. Install them only if you import /shell.
pnpm add next next-themes
imports
// Tier 1 + Tier 2 — primitives, composites, cn, tokens
import { Button, DataTable, PageHeader, cn } from '@comitor/ui'

// Tier 3 — the frame. Separate entry, separate peers.
import { AppShell, ShellProvider, WorkspaceSwitcher, AppLauncher } from '@comitor/ui/shell'

Every component is a client component

State, routing and key handling all live here, so every component in the entry carries "use client" — the two brand marks excepted, so a server component can render them. The type and constant modules stay pure on purpose: a server root layout reads CONTRAST_STORAGE_KEY straight from one, and re-exporting it through a "use client" file turns the constant into a proxy that reads undefined, with no error anywhere.

The imports carry .js

The shell reads next/link.js, extension and all. next publishes no exports map, so bare next/link resolves under a bundler but throws ERR_MODULE_NOT_FOUND under plain Node ESM — which made the entry invisible to every CI smoke test. Same file either way.

Not on Next?

Pass pathname and LinkComponent and the shell never calls the Next router — it never calls useRouter() anyway, because that hook throws without an App Router. The module still imports next at load, so the package has to resolve.

The two navigation axes

A Comitor user belongs to many workspaces, and each workspace has many products switched on. The two are perpendicular — changing workspace does not change which product you are in, and changing product does not change which workspace you are in — so they cannot collapse into one flat menu.

Axis 1 — Workspace

WorkspaceSwitcher

Changes the data context: Acme Corp ▸ Beta Ltd. It only calls onWorkspaceChange(id) — swapping workspace swaps the data and the permissions with it, which is far too consequential for the frame to decide on its own.

Axis 2 — App

AppLauncher

Changes the product inside one workspace: Tasks ▸ Chat ▸ CRM ▸ HR. Products the workspace has not bought stay in the grid with a lock and call onUpsell(appId) rather than navigating.

Tasks
Chat
CRM
HR
Acme Corp
you are here
·
·
·
Beta Ltd
·
·
·
·

WorkspaceSwitcher moves you down a column; AppLauncher moves you along a row. A single flat menu can only express one of the two.

Neither control takes its data as a prop. Both read ShellProvider context, which AppShell already mounts — so workspaces, apps, nav and user are passed once, at the top. Assembling the parts by hand works the same way: wrap them in a ShellProvider and put them wherever you like.

Do not confuse these with the four display axes — light/dark, colour palette, layout density, font size. Those are a separate, equally independent set, documented at Display Axes.

The frame, live

A real AppShell, boxed to 460px. Both axes work, the sidebar collapses, the launcher grid takes arrow keys, the locked CRM tile calls onUpsell, and the search pill opens a palette passed in through commandPaletteSlot. Nothing navigates: the demo supplies its own LinkComponent.

Layout
Inbox
Workspace
Acme Corp
App
Tasks
Path
/tasks/inbox

Switch workspace in the top-left control and the app stays put. Open the 3×3 launcher in the header and the app changes — along with the sidebar menu, because the menu belongs to the app, not to the workspace. That is the whole point of two axes.

last callback:

Your viewport is under the md breakpoint, so the sidebar is not rendered at all — that is the real behaviour. The hamburger opens MobileMenu instead.

Two things are turned off that an app leaves on. enableShortcuts={false} and persistSidebarState={false}, so a documentation page does not claim ⌘K site-wide or write to your localStorage. (collapseSidebarOnTablet is off for the same reason — the box is not the viewport, so the tablet default would fire at the wrong width.)

Every route to next-themes is off too, and there are three of them: the header’s showThemeToggle, the user menu’s showThemeSubmenu (which defaults to "mobile" — rendered, and only hidden by md:hidden), and MobileMenu’s own toggle. next-themes keeps one context per document, so any of the three would flip this whole site rather than the box. A real app leaves all three on — and note the pairing while you are here: turn the header toggle off in an app and you must turn the user menu’s submenu on, or wide screens lose every way to change theme.

The two layouts

Same components, same context, two arrangements of the outer flex container. Switch between them with the buttons above the demo.

sidebar-first

The default. Linear, Lark.

  • Sidebar runs the full height of the window; the header sits to its right.
  • The workspace switcher is the top strip of the sidebar, and the collapse button its bottom one.
  • The header carries no sidebar toggle — the sidebar has its own.
  • Reads as one product with sections. Best when the nav is the spine of the app.

header-first

Full-width header on top.

  • Header spans the window; sidebar and content sit underneath it.
  • The workspace switcher moves into the header, as a 224px block in the logo slot, and the sidebar drops its own copy.
  • The header grows a sidebar toggle instead, because the sidebar no longer has room for the collapse button.
  • Reads as one company with products. Best when the header is shared brand furniture.

Those flips are defaults, not rules — every one of them is a prop you can set on headerProps or sidebarProps. What the frame protects is the invariant behind them: exactly one workspace switcher and exactly one collapse control on screen. Two identical buttons in two places is not a richer UI, it is a user wondering which one is real. The defaults are applied after your props are spread, with ??, so passing headerProps without mentioning them does not clobber them with undefined.

Below the md breakpoint the choice stops mattering: the sidebar is not rendered at either setting, and both layouts collapse to the header plus the off-canvas MobileMenu. That menu puts the current app’s nav above the app list, the reverse of the desktop arrangement — with eight or ten products in the ecosystem, listing them first pushes the menu you actually opened the sheet for below the fold.

What goes in the root layout, and what goes in the shell

Two files, and the split between them is not cosmetic. The four display-axis providers belong at the root, outside AppShell; the frame itself belongs in the segment layout that owns the app’s data.

app/layout.tsx
// app/layout.tsx — a SERVER component. No shell components here, only providers.
import { ContrastProvider, DensityProvider, FontSizeProvider, ThemeProvider } from '@comitor/ui/shell'
import { Inter } from 'next/font/google'
import './globals.css'

const inter = Inter({
  // 'vietnamese' is not optional: without it the diacritics fall back to a system
  // font and the text visibly changes shape mid-sentence.
  subsets: ['latin', 'vietnamese'],
  variable: '--font-inter',
})

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    // suppressHydrationWarning is REQUIRED, not tidiness: all four anti-flash scripts
    // mutate <html> before React hydrates, so the client markup never matches the server.
    <html lang="en" suppressHydrationWarning className={inter.variable}>
      <body className="min-h-dvh bg-background font-sans text-foreground antialiased">
        {/* All four are order-independent: each writes its own attribute on <html> and none
            of them reads another. What matters is that they sit at the ROOT, above AppShell. */}
        <ThemeProvider>
          <ContrastProvider>
            <DensityProvider>
              <FontSizeProvider>{children}</FontSizeProvider>
            </DensityProvider>
          </ContrastProvider>
        </ThemeProvider>
      </body>
    </html>
  )
}
app/(app)/layout.tsx
'use client'

// app/(app)/layout.tsx — a CLIENT component, and it has to be one: every callback
// below is a function, and AppDescriptor.icon / NavItem.icon are React components.
// Neither crosses the server → client boundary.

import { AppShell, SonnerToaster } from '@comitor/ui/shell'
import { useRouter } from 'next/navigation'
import type { ReactNode } from 'react'
import { CommandPaletteSlot } from '@/components/command-palette-slot'
import { NotificationsSlot } from '@/components/notifications-slot'
import { apps, nav, user, workspaces } from '@/lib/shell-data'

export default function AppLayout({ children }: { children: ReactNode }) {
  const router = useRouter()

  return (
    <>
      <AppShell
        workspaces={workspaces}
        currentWorkspaceId="acme"
        apps={apps}
        currentAppId="tasks"
        nav={nav}
        user={user}
        // Switching workspace swaps the whole data context and the permissions with it,
        // so the shell never decides it — it hands you the id and stops.
        onWorkspaceChange={(id) => router.push(`/w/${id}`)}
        onAppSelect={(app) => router.push(app.href)}
        onUpsell={(appId) => router.push(`/billing?app=${appId}`)}
        onLogout={() => router.push('/sign-out')}
        headerProps={{
          // The package ships the bell button only; the panel behind it is app data.
          notificationsSlot: <NotificationsSlot />,
          userMenuProps: { settingsHref: '/settings' },
        }}
        // Rendered inside ShellProvider, so useShell() works in there.
        commandPaletteSlot={<CommandPaletteSlot />}
      >
        {children}
      </AppShell>

      {/* OUTSIDE the frame. AppShell is h-dvh + overflow-hidden; a toaster inside it is
          asking to be clipped. SonnerToaster is the next-themes-aware one — the plain
          Toaster at '@comitor/ui' is for apps without next-themes. */}
      <SonnerToaster />
    </>
  )
}

Four things that are easy to get wrong

  • The four providers go at the root, above AppShell. Their nesting order is arbitrary — none of them knows the others exist, and each writes only its own attribute on <html>.
  • suppressHydrationWarning on <html>. All four anti-flash scripts mutate that tag before React hydrates, so client markup never matches server markup. The providers render those scripts themselves, in <body> ahead of your content — React 19 logs a warning on every client render if a script is put in <head>, and the body position blocks the paint just the same.
  • The segment layout must be a client component. Every callback is a function, and AppDescriptor.icon / NavItem.icon are React components. Neither survives the server → client boundary, so the nav and app tables have to be imported from inside the client file.
  • The toaster goes outside the frame. AppShell is h-dvh overflow-hidden; a toaster inside it is asking to be clipped. Mount SonnerToaster as its sibling, once for the whole app.

One optional extra: searchParams

Only needed when two menu rows differ by query rather than by path. Pass it and the shell can tell ?tab=general from ?tab=members; leave it out and every row on that path lights up together. It accepts a URLSearchParams, a "?tab=x" string, or a server component’s searchParams object.

shell.tsx
'use client'

import { useSearchParams } from 'next/navigation'
import { AppShell } from '@comitor/ui/shell'
import { nav } from '@/lib/shell-data'

/**
 * Only needed when menu rows differ by QUERY — /settings?tab=general vs
 * /settings?tab=members. usePathname() never carries a query, so without this every
 * row on /settings lights up at once.
 *
 * The shell deliberately does not call useSearchParams() itself: that hook forces a
 * <Suspense> boundary on the page and pushes static builds to client rendering. That
 * cost has to be the app's choice, not a side effect of importing the shell.
 */
export function Shell({ children }: { children: React.ReactNode }) {
  const searchParams = useSearchParams()
  return <AppShell nav={nav} searchParams={searchParams}>{children}</AppShell>
}

AppShell — frame props

These are the props that shape the frame. AppShellProps also extends ShellProviderProps — the data, the callbacks, labels, searchParams and the state flags — all of which are documented on App Shell.

PropTypeDefaultDescription
layout'sidebar-first' | 'header-first''sidebar-first'Which of the two frames to build. sidebar-first runs the sidebar the full height of the window with the workspace switcher at its top (Linear, Lark); header-first runs a full-width header across the top and moves the switcher into it.
showSidebarbooleantrueDrop the sidebar entirely — for a single-area product that only needs a header. MobileMenu is still rendered, so narrow screens keep a way into the nav.
headerPropsAppHeaderPropsForwarded to AppHeader: title, breadcrumb, logo, searchSlot, notificationsSlot, userMenuProps, and the showSearch / showAppLauncher / showThemeToggle / showUserMenu switches. Two values are computed from layout when you leave them undefined — see below.
sidebarPropsSidebarPropsForwarded to Sidebar: header and footer slots, showWorkspaceSwitcher, showCollapseButton. The last two also default from layout.
mobileMenuPropsMobileMenuPropsForwarded to MobileMenu, the off-canvas sheet under the md breakpoint. Turning off its showAppLauncher removes the only route to the app list on narrow screens — the header launcher is hidden below md.
commandPaletteSlotReactNodeWhere the app’s own Cmd/Ctrl+K palette goes. Rendered inside ShellProvider, so the component you pass can call useShell() and read commandPaletteOpen. Leave it empty and the header search pill plus the ⌘K shortcut become dead controls — the shell owns the open state, never the content.
classNamestringClasses on the outer frame, which is h-dvh w-full overflow-hidden by default. Merged through cn(), so a height passed here replaces the default rather than fighting it.
contentClassNamestringClasses on the <main> element. main is relative on purpose: without it, absolutely positioned descendants (Tailwind’s sr-only, Radix’s hidden native inputs) escape its overflow clip and stretch the document’s scroll height — measured at 631px of blank space below one page, 892px below another.

Everything in this tier

Six pages under /shell, one per part of the frame.

Shell exports documented elsewhere

These four come out of @comitor/ui/shell too, but they are filed with the components and the foundations rather than repeated here.

Strings: the ShellLabels contract

The package speaks Vietnamese by default — including the strings only a screen reader hears (aria-label, sr-only). An app in another language overrides them through one prop instead of forking or re-wrapping anything.

This is a tier-wide concern, not a per-component one. There is exactly one label set for all of Tier 3: pass labels to AppShell (or ShellProvider) and every part reads it from context — sidebar, header, both switchers, the user menu, the mobile sheet. The provider spreads your object over DEFAULT_SHELL_LABELS, so Partial<ShellLabels> is enough and a single key overrides cleanly.

When a key is both visible text and an accessible name, override both

The collapse button carries two of them, and they are split on purpose. collapseSidebar is the accessible name — the aria-label and the tooltip, heard out of context, so it has to say what it collapses. collapseSidebarShort is the text printed inside a 240px button where the context is already obvious.

WCAG 2.5.3 Label in Name requires the visible string to be contained in the accessible name: Collapse Collapse sidebar ✓. Keep that relationship in every translation, or the voice command “click Collapse” stops matching the button. The same rule catches the commonest mistake on a bilingual page: overriding an English label onto a Vietnamese visible string leaves an accessible name that has nothing to do with what is on screen.

ShellLabels is 33 keys in 1.0.0 — the same set as 0.9.1, with no key added or removed. Every one is a plain string except members, which is a function of the member count: it interpolates a number, and word order around a number changes with the language. (The package README still says 32; it predates collapseSidebarShort, added in 0.5.0.) Here is the whole thing in English, the same object this page’s demo runs on:

lib/shell-labels.ts
import type { ShellLabels } from '@comitor/ui/shell'

/**
 * All 33 keys. Typed as the full interface rather than Partial<ShellLabels>: when the
 * package adds a key this object goes red in tsc, instead of one corner of the frame
 * quietly reverting to Vietnamese.
 */
export const EN_SHELL_LABELS: ShellLabels = {
  /* Sidebar & header */
  collapseSidebar: 'Collapse sidebar',
  collapseSidebarShort: 'Collapse',
  expandSidebar: 'Expand sidebar',
  openMenu: 'Open menu',
  closeMenu: 'Close menu',
  search: 'Search',
  searchPlaceholder: 'Search…',
  notifications: 'Notifications',
  navigationLabel: 'Main navigation',
  /* Workspace switcher */
  workspace: 'Workspace',
  workspaceSwitcherLabel: 'Switch workspace',
  workspaceSearchPlaceholder: 'Find a workspace…',
  workspaceEmpty: 'No workspace found',
  createWorkspace: 'Create workspace',
  workspaceSettings: 'Workspace settings',
  inviteMembers: 'Invite members',
  members: (count) => `${count} members`,
  /* App launcher */
  apps: 'Apps',
  appLauncherLabel: 'Open the app list',
  myApps: 'Your apps',
  discoverApps: 'Discover more',
  locked: 'Locked',
  lockedHint: 'Not included in the current plan',
  /* User menu */
  account: 'Account',
  profile: 'Profile',
  settings: 'Settings',
  appearance: 'Appearance',
  help: 'Help',
  logout: 'Sign out',
  /* Theme */
  themeLight: 'Light',
  themeDark: 'Dark',
  themeSystem: 'System',
  toggleTheme: 'Change theme',
}

// One place, one language switch — every Tier 3 component reads it from context:
//
//   <AppShell labels={EN_SHELL_LABELS} …>{children}</AppShell>

Two toggles take their strings on a labels prop of their own, because both are usable on a sign-in or marketing page with no ShellProvider above them. Only one of the two is really outside the set: ContrastToggle has a contract of its own, ContrastToggleLabels — two keys, label and description, neither of them in ShellLabels. ThemeToggleLabels is not a second set at all: it is Pick<ShellLabels, …> over the four theme keys above — the same strings, arriving by prop instead of by context. Inside AppShell, the header passes the shell’s labels to the toggle for you.

Accessibility

  • The frame is built from real landmarks: the sidebar is an <aside> containing a <nav> labelled with labels.navigationLabel, the header is a <header>, and content is a single <main> — so landmark navigation reaches each region without any ARIA scaffolding.
  • Shortcuts are global but yield to typing: the handler bails out on input, textarea, select and any contenteditable before it looks at the key. Escape is the exception and always closes the palette, the mobile menu and the launcher. ⌘/Ctrl+K opens the palette, ⌘/Ctrl+Shift+K the launcher, ⌘/Ctrl+B collapses the sidebar, and Alt+1…9 jumps to the nth entitled app — read from event.code, not event.key, because Alt+1 on macOS produces “¡”. The whole set is one prop away: enableShortcuts={false}.
  • Changing route closes every open overlay, so keyboard focus is never left inside a panel that is describing the page you just left.
  • Every string the frame speaks is overridable, screen-reader-only ones included. That is the point of ShellLabels: an English page whose accessible names are still Vietnamese looks perfect in a screenshot and fails for the people who cannot see it.
  • The font-size axis never writes an absolute size onto <html>. Its default removes the attribute entirely, and sm / lg are percentages of whatever the browser is set to (87.5% / 112.5%). Declaring a fixed size would erase the reader’s own preference — WCAG 1.4.4, violated from inside an accessibility feature.
  • A locked app tile stays in the tab order and stays clickable, so it gets no exemption from the contrast rules: it carries no opacity dimming, and says “locked” through a grey icon plate, a padlock badge and its accessible name rather than through colour or fade alone.
  • The mobile menu is a Radix sheet with a real SheetTitle (visually hidden, carrying labels.navigationLabel), so it announces itself on open and traps focus while it is up.