Shell

App Header

The 56px bar across the top of every Comitor app: where you are on the left, search in the middle, and the cluster that switches product, theme and account on the right. It is a set of slots over shell context — it holds no state, renders no panels, and fetches nothing.

Live demo

AppHeader reads useShell(), so it throws outside a ShellProvider. Open the app launcher, the user menu, the theme button; click the search pill and a real CommandPalette opens, because this demo mounts one — the package does not. Below lg the pill collapses into an icon in the right-hand cluster, and below md a hamburger appears that opens the full-height off-canvas MobileMenu, which is fixed and so escapes this box.

Comitor

The header holds no state of its own. Every control writes to useShell() — the toggle to sidebarCollapsed (no sidebar in this box, so watch the chip and the button’s tooltip), the search pill to commandPaletteOpen, which the palette mounted below is listening to.

sidebarCollapsed: falsecommandPaletteOpen: falseappLauncherOpen: falsemobileMenuOpen: false

Three regions, and why search is in the middle

The bar is three flex children: flex-1 · shrink-0 · flex-1. That is the whole centring mechanism, and every constraint below follows from it.

RegionSizingHolds
leftflex-1 min-w-0Hamburger (below md), sidebar toggle, logo, then breadcrumb or title.
centreshrink-0The 256px search pill, and nothing else. Empty below lg, where it collapses to zero width.
rightflex-1 shrink-0extraSlot, the icon-sized search below lg, app launcher, notifications, theme, divider, user menu.
  • Two equal flexible sides are what centre the pill. Put search in the right-hand cluster instead and it drifts with the number of buttons next to it; centre it with absolute left-1/2 and it lands on top of a long breadcrumb. The three-region split avoids both, at every breadcrumb length.
  • The pill appears at lg, not sm. It is shrink-0, so it takes a fixed 256px out of the content column. The dangerous band is 768–1023px: the sidebar is already showing and eating 256px, leaving a 512px content column — minus the pill, that is 104px per side for a button cluster about 190px wide. Measured at 768px before the fix, the three header buttons were squeezed to 16px each.
  • The icon-sized search sits in the right cluster, not the centre. The centre region is centred; an icon left there would float alone in the middle of the bar while every other icon hugs the right edge.
  • The right cluster is shrink-0 on purpose. Without it flexbox squeezes the buttons rather than the pill — the same 16px-wide buttons as above.
  • Comitor is a light system. The bar is bg-background/80 backdrop-blur-sm with a border-border hairline — never an always-dark header pinned above a light page.

Title, breadcrumb, and the h1 between them

The left region renders breadcrumb ?? title: pass both and the breadcrumb wins outright — the title is not rendered anywhere, at any width. The choice is not only cosmetic. title is wrapped in an <h1>, and so is PageHeader in the content area — use both and the page has two. Pick the header title for bare surfaces, the breadcrumb for pages whose body already introduces itself.

title-vs-breadcrumb.tsx
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator,
} from '@comitor/ui'
import { AppHeader } from '@comitor/ui/shell'

// `breadcrumb ?? title` — pass both and the breadcrumb wins, the title never
// renders. Pick one per surface rather than passing a fallback.

// Plain title. Renders
// <h1 class="truncate text-sm font-semibold text-foreground">Inbox</h1>, so this
// is the page's only h1 — do not also mount a PageHeader below it.
export function TitleHeader() {
  return <AppHeader title="Inbox" />
}

// Breadcrumb. Renders exactly the node you hand it and nothing else, which is
// what you want when PageHeader in the content area owns the h1.
export function BreadcrumbHeader() {
  return (
    <AppHeader
      // A slot is outside the ShellLabels contract: this <nav> carries the
      // Breadcrumb primitive's own Vietnamese aria-label until you override it.
      breadcrumb={
        <Breadcrumb aria-label="Breadcrumb">
          <BreadcrumbList>
            <BreadcrumbItem>
              <BreadcrumbLink href="/tasks">Tasks</BreadcrumbLink>
            </BreadcrumbItem>
            <BreadcrumbSeparator />
            <BreadcrumbItem>
              <BreadcrumbPage>Inbox</BreadcrumbPage>
            </BreadcrumbItem>
          </BreadcrumbList>
        </Breadcrumb>
      }
    />
  )
}

Code only, deliberately: a live title demo would put a second <h1> into this documentation page — exactly the mistake the paragraph above is warning about. The demo at the top of the page uses the breadcrumb form for the same reason.

Search, and the ⌘K hint that starts as Ctrl

The search affordance is a <button>, not an input. It calls setCommandPaletteOpen(true) and stops there: the package ships no palette contents, because what is searchable is the app’s business. Mount CommandPalette yourself — with registerShortcut={false}, since ShellProvider already binds ⌘/Ctrl+K and two listeners on one boolean open and close it inside a single keypress.

search-slots.tsx
import { Input } from '@comitor/ui'
import { AppHeader } from '@comitor/ui/shell'

// 1 — built-in affordance (default). A button, not an input: it opens the
//     command palette, so it never holds a value or takes a keystroke.
export function DefaultSearch() {
  return <AppHeader />
}

// 2 — your own search UI in the centre region. Note it stays in the centre at
//     every width: the package's icon-sized fallback is only built for the
//     built-in button, so a custom slot has to handle its own narrowing —
//     hence `w-36 lg:w-64` rather than a flat 256px that crushes the bar at 375px.
export function OwnSearch({ onQuery }: { onQuery: (value: string) => void }) {
  return (
    <AppHeader
      searchSlot={
        <Input
          className="h-8 w-36 rounded-full lg:w-64"
          placeholder="Search tasks…"
          onChange={(event) => onQuery(event.target.value)}
        />
      }
    />
  )
}

// 3 — no search at all. The ⌘K shortcut still fires from ShellProvider and
//     still flips `commandPaletteOpen`; only the affordance is gone.
export function NoSearch() {
  return <AppHeader showSearch={false} />
}

Why the hint says “Ctrl” for one frame on a Mac

The Kbd in the pill is fed by useIsMac(), which returns false on the first render even on a Mac. That is the design, not a bug: the server has no idea what machine the visitor is on, and a guess that turns out wrong is a hydration mismatch — React warns, and sometimes throws the subtree away. Showing “CtrlK” for one frame and then swapping to “⌘K” is the cheaper trade.

It is built on useSyncExternalStore with a server snapshot of false, so React reconciles the two renders itself — no manual mounted flag. Client-side it reads navigator.platform before userAgent, because recent Safari and Chrome have trimmed the user agent while platform still answers “MacIntel”.

The same hook drives the sidebar toggle’s tooltip (“Collapse sidebar · ⌘ + B”). Import it from @comitor/ui — it is the only export gate in the package, and @comitor/ui/shell does not re-export it.

The right-hand cluster

Six things in fixed order, each of them droppable. Two of them are hidden below md and picked up elsewhere, which is the part worth reading twice.

  • extraSlot — first, so an app’s own primary action sits furthest from the account menu.
  • Search icon — the pill’s narrow form, present only when showSearch is on and you have not supplied a searchSlot.
  • AppLauncher — navigation axis 2, the 3×3 grid that changes product inside the current workspace. Rendered hidden md:flex: below md, MobileMenu already carries <AppLauncher inline variant="list" />, and on a narrow bar every 36px counts. Switch off both showAppLauncher here and the one in MobileMenu and narrow screens lose every route to the app list.
  • NotificationsButton — see the next section; it appears only if you pass notificationCount or notificationsSlot.
  • ThemeToggle — also hidden md:flex, and paired with UserMenu’s showThemeSubmenu="mobile", which is md:hidden. The same breakpoint on both sides means exactly one theme control at every width — never two, never none. Move one and you must move the other. Light/dark is one of four independent display axes; the header only carries this one.
  • UserMenu — preceded by a hairline divider, and both disappear together when context has no user. Configure it through userMenuProps; the built-in entries only render for the hrefs you actually supply.

Axis 1 — the WorkspaceSwitcher — is deliberately not in this list. In the default sidebar-first layout it lives at the top of the sidebar; it only moves into the header’s logo slot in header-first, and AppShell does that swap for you so the same control never shows up twice.

The mobile hamburger

The leftmost button is md:hidden and does exactly one thing: setMobileMenuOpen(true). It renders no drawer. What listens is MobileMenu, a left-hand Sheet that AppShell mounts for you — hand-built frames must mount it themselves, or the hamburger becomes a button that flips a flag nobody reads. Its accessible name is labels.openMenu; the sheet closes itself on a route change and on any real link click inside it.

Note the visibility split: the hamburger is md:hidden while Sidebar is hidden md:flex. Both are pure CSS — no matchMedia, no viewport measurement during the first render, so nothing here can mismatch on hydration.

NotificationsButton — a button, and nothing else

This is the one Tier 3 component defined as much by what it refuses to ship. There is no notifications panel in @comitor/ui/shell, on purpose: every app’s notification rows have a different shape — a task mention, a chat thread, an SLA breach, an invoice — and a panel baked into the package would force each of them to bend its data into one borrowed schema. So the package owns the bell, the unread badge and the accessible name; the app owns the panel and wraps the button in its own overlay.

Left to right above: count={0} (bell, no badge), 3, 9, 23 (clipped to “9+” by the default max), and 23 again with max={99}. On the right, the same button as a PopoverTrigger — open it and Radix’s asChild adds aria-haspopup="dialog" and aria-expanded to it, because every extra prop lands on the underlying <button>.

counts.tsx
import { AppHeader, NotificationsButton } from '@comitor/ui/shell'

// No prop at all → no bell in the header. Not a bell with nothing in it:
// `notificationCount === undefined` drops the whole button.
export const NoBell = () => <AppHeader />

// 0 → bell, no badge. Use this when the feature exists but is quiet.
export const Quiet = ({ open }: { open: () => void }) => (
  <AppHeader notificationCount={0} onNotificationsClick={open} />
)

// 23 with the default max of 9 → the badge reads "9+", the accessible name
// still reads "Notifications (23)".
export const Busy = ({ open }: { open: () => void }) => (
  <AppHeader notificationCount={23} onNotificationsClick={open} />
)

// Standalone, outside the header. It needs no ShellProvider — but it also
// gets no shell labels, so its aria-label falls back to Vietnamese
// ("Thông báo") unless you pass `label`.
export const Loose = () => <NotificationsButton count={23} max={99} label="Notifications" />
  • The badge is pushed to the corner, not inset. right-1 top-1 covered 39% of the bell — you saw a red blob, not a bell. Negative offsets covered only 6% but floated the badge off the button, where it reads as a loose dot belonging to nothing, and header buttons are only gap-1 apart. top-0 right-0 covers 14% and stays attached.
  • ring-2 ring-background is the other half of that. A ring in the header’s own background colour cuts a clean line between badge and bell, so the 14% overlap reads as two shapes instead of one smear.
  • Colour follows the role contract. bg-destructive is a solid fill and carries its own ink, text-destructive-foreground — the pair, never a raw scale step, so both palettes and both themes stay legible.
  • It needs no shell context. NotificationsButton never calls useShell(), so it works on a marketing page or in a hand-built frame — but then it also gets no shell labels and falls back to the Vietnamese default for its accessible name. Pass label outside the header.

Inside AppShell

Most apps never mount this component directly. AppShell renders it and forwards everything through headerProps, filling in two defaults that depend on the layout — and only where you left the prop out, because it spreads your props first and resolves the defaults afterwards.

app/(tasks)/layout.tsx
'use client'

import type { ReactNode } from 'react'
import { AppShell, ComitorLockup, type ShellUser } from '@comitor/ui/shell'
import { NewTaskButton } from './new-task-button'
import { APPS, NAV, WORKSPACES } from './shell-model'

interface TasksLayoutProps {
  children: ReactNode
  user: ShellUser
  unread: number
  openDrawer: () => void
}

export function TasksLayout({ children, user, unread, openDrawer }: TasksLayoutProps) {
  return (
    <AppShell
      workspaces={WORKSPACES}
      apps={APPS}
      currentAppId="tasks"
      nav={NAV}
      user={user}
      // Everything below reaches <AppHeader /> unchanged.
      headerProps={{
        logo: <ComitorLockup size="xs" variant="auto" />,
        notificationCount: unread,
        onNotificationsClick: openDrawer,
        extraSlot: <NewTaskButton />,
        userMenuProps: { profileHref: '/profile', settingsHref: '/settings' },
      }}
    >
      {children}
    </AppShell>
  )
}

// Two header props AppShell fills in for you, and only when you leave them out:
//
//   layout="sidebar-first" (default)  showSidebarToggle: false
//                                     logo:              undefined
//   layout="header-first"             showSidebarToggle: true
//                                     logo:              <WorkspaceSwitcher className="w-56 shrink-0" />
//
// In header-first the switcher moves out of the sidebar and into the logo slot,
// so passing your own `logo` there costs you the workspace switcher.

AppHeader props

PropTypeDefaultDescription
titleReactNodePage title for the left region. Rendered as <h1 class="truncate text-sm font-semibold text-foreground"> — the header owns the page’s only h1 when you use it. Ignored whenever breadcrumb is present.
breadcrumbReactNodeBreadcrumb slot, rendered verbatim. Wins over title (breadcrumb ?? title). Use it when the content area already has a PageHeader, which renders its own h1.
logoReactNodeLeftmost slot, after the hamburger and the sidebar toggle — a ComitorLockup size="xs", a product mark, anything shrink-0. AppShell fills it with WorkspaceSwitcher in the header-first layout.
searchSlotReactNodeReplaces the built-in search button in the centre region. A custom slot has no icon-sized fallback, so it stays in the centre below lg and must narrow itself.
showSearchbooleantrueRender the search affordance. False removes the pill and the icon button; the ⌘/Ctrl+K shortcut in ShellProvider keeps working regardless.
showSidebarTogglebooleanfalseShow the collapse/expand button in the header. Off by default because in sidebar-first the button lives at the bottom of the sidebar; AppShell turns it on for header-first.
notificationsSlotReactNodeYour own notifications control — typically a Popover wrapping NotificationsButton. Present, it takes over completely: notificationCount and onNotificationsClick are ignored.
notificationCountnumberUnread count for the built-in bell. Left undefined the bell is not rendered at all; 0 renders a bell with no badge. Anything above the button’s max collapses to "max+" — "9+" with the default max of 9.
onNotificationsClick() => voidClick handler for the built-in bell. The package ships no panel, so without this (or notificationsSlot) the bell does nothing.
extraSlotReactNodeRight-hand slot, first in the cluster — before the app launcher. A "New task" button, a workspace-wide status pill, a trial countdown.
showAppLauncherbooleantrueThe 3×3 grid button, axis 2. Rendered hidden md:flex — below md the same list lives inside MobileMenu, so turning both off leaves narrow screens with no way to change app.
showThemeTogglebooleantrueThe light/dark button, also hidden md:flex. It pairs with UserMenu’s showThemeSubmenu="mobile" on the same md breakpoint, so there is exactly one theme control at every width.
showUserMenubooleantrueThe avatar menu plus the thin divider before it. Both are dropped when context carries no user, so a signed-out shell leaves no orphan separator.
userMenuPropsUserMenuPropsForwarded to UserMenu: profileHref, settingsHref, helpHref, items, showName, showThemeSubmenu, align, onLogout.
classNamestringExtra classes on the <header>. Height (h-header), sticky top-0 z-40, the border and the translucent blurred background come from the component.

NotificationsButton props

PropTypeDefaultDescription
countnumber0Unread count. 0 renders the bell with no badge; anything above 0 pushes the badge into the top-right corner.
labelstringDEFAULT_SHELL_LABELS.notificationsAccessible name. Defaults to the Vietnamese "Thông báo" — AppHeader passes labels.notifications for you, standalone use must pass it. Once count is above 0 the name becomes `${label} (${count})`.
maxnumber9Above this the badge shows "max+" so the pill never outgrows the 36px button. Only the visible digits are clipped: the accessible name keeps the exact count.
classNamestringMerged onto the button. Size, radius, hover colours and the badge geometry come from the component.
…ComponentProps<"button">Omit<ComponentProps<"button">, "children">Everything else lands on the <button>: onClick, disabled, ref, and the aria-haspopup / aria-expanded that PopoverTrigger asChild injects. children is excluded — the bell and the badge are the content.

Strings

Every string in the header — including the ones only a screen reader hears — comes from the ShellLabels object that ShellProvider spreads over DEFAULT_SHELL_LABELS, so a partial override changes only the keys you name. The header itself reads six: openMenu, collapseSidebar, expandSidebar, search, searchPlaceholder and notifications. The components it mounts read the rest, so an English app overrides the whole set rather than the header’s six — otherwise the app launcher, user menu and theme button keep answering in Vietnamese.

Watch the labels that do two jobs at once. In the search pill, searchPlaceholder is the visible text and, together with the ⌘K hint, the button’s whole accessible name. Elsewhere in the shell the split is explicit — the sidebar’s collapse button shows collapseSidebarShort and announces collapseSidebar. Override both halves, and keep the short string a substring of the long one, or a voice command spoken from what is on screen will not match the accessible name (WCAG 2.5.3, Label in Name).

Slots are outside the contract. labels reaches what AppHeader renders, not what you hand it — the Breadcrumb primitive in the demo above ships its own Vietnamese aria-label on the <nav>, and only an explicit aria-label="Breadcrumb" changes it.

Accessibility

  • The component renders a real <header>, so it is the page’s banner landmark. Mount one per page — a second AppHeader inside the content area would produce two banners for a screen reader to choose between.
  • Every icon-only control carries an aria-label taken from labels: the hamburger (openMenu), the narrow search button (search), and the sidebar toggle, whose name flips between collapseSidebar and expandSidebar with the state it reports. Its tooltip repeats that name and appends the shortcut, so the mouse and the screen reader are told the same thing.
  • The search pill has no separate label: its accessible name is its content — the placeholder plus the Kbd hint, read as “Search… ⌘K”. Which is also why it is a button and never an <input>: an input that throws away keystrokes to open a dialog is a trap for anyone who starts typing into it.
  • The unread count is inside the accessible name, not only in the badge: Notifications (23) even when the badge is clipped to “9+”. The badge is not a live region, though — a count that changes while the page is open is announced to nobody, so an app that needs that must add its own aria-live region.
  • The pill’s hover state thickens its border instead of thinning it. The obvious brand choice, border-gold-300, measures 1.86:1 against the page and 1.73:1 against the hover background — the border effectively vanishes at the moment you point at it, far under the 3:1 that WCAG 1.4.11 asks of a control boundary. Resting is border-input (3.83:1 light / 4.94:1 dark), hover is --control-edge-strong (10.55:1 light / 11.65:1 dark against the page). A raw scale step would also be one fixed value across both themes, so the same class that is nearly invisible in light measures 10.19:1 in dark; the role token moves with the palette.
  • Icon buttons are 36×36 (size-9) with gap-1 between them, clearing the 24×24 minimum of WCAG 2.5.8 with room to spare, and none of them relies on hover to be discoverable.
  • Responsive hiding is done with display: none, which removes the element from the accessibility tree as well as the layout. That is why the theme control appears at exactly one place per width — a desktop screen-reader user never finds a duplicated “Appearance” entry hidden in the user menu, and a mobile one never finds nothing at all.
  • The bar is sticky top-0 z-40, but inside AppShell it is a flex sibling above the scrolling <main> rather than floating over it, so it can never cover a focused element (WCAG 2.4.11). Keep that relationship if you build your own frame.

Related