Composites
Page Header
The standard frame every screen in the ecosystem starts with: PageHeader for the breadcrumb, title and actions, and PageContainer for the body underneath. Both are static components with no client state, so they render straight from a Server Component.
Basic usage
A title, an optional description, and a slot for the screen's primary actions. The title becomes the page's <h1>, so there should be exactly one PageHeader per screen.
Tasks
Everything in this workspace — filter, sort and act in bulk.
The header is w-full with px-4 md:px-6 — the same horizontal padding PageContainer uses, which is what keeps the title aligned with the content below it.
Breadcrumb
Pass an array of { label, href? } and the header builds a full Breadcrumb above the title. The last item is always rendered as the current page — non-clickable, marked aria-current="page" — even if you gave it an href. The crumbs below are real links; try them.
Page Header
The crumb above is a real link — it goes to the composites index.
LinkComponent exists because the . entry of the package must resolve outside Next — Vite, Storybook, a test runner. A single import 'next/link' in this file would break all of those the moment an app imported Button. The same convention appears on RouteTabs and the shell components.
Icon
icon takes a component, not an element: icon={CalendarCheck}, never icon={<CalendarCheck />}. A Lucide icon is a forwardRef object rather than a function, so a component and an element cannot be told apart at runtime — the package types the slot narrowly and lets the compiler catch the mistake instead of guessing wrong in silence. It is the same IconComponent type used by EmptyState and FilterChips.
Sprint 24
Ends Friday · 18 of 31 tasks closed.
The chip is filled with bg-app-accent/10 — a tint of whatever accent the current product sets — while the glyph stays text-foreground. That split is the package's three-role colour contract: --app-accent fills backgrounds and nothing else. The tempting version of this pattern shipped once — 0.1.0's AppLauncher drew the icon in the accent colour on a 15% accent tint — and with the default gold accent it measured 1.69:1, well under the 3:1 WCAG 1.4.11 asks of graphical objects. No shade of gold fixes that pair, which is why the roles are separate variables rather than a rule nobody enforces. If your app overrides --app-accent, override --app-accent-ink alongside it.
Secondary row
Anything passed as children renders under the title and above the bottom rule — route tabs, a filter bar, status chips. The chips below are live; toggling them changes real state.
Tasks
Everything in this workspace — filter, sort and act in bulk.
Note the English strings. FilterChips defaults allLabel to "Tất cả" and its screen-reader group name to "Bộ lọc nhanh", like most of the package. PageHeader itself has no strings of its own to translate — with one exception, covered under Accessibility.
Sticky and borderless
sticky adds sticky top-0 z-20 plus backdrop-blur, so the title and primary action stay reachable down a long list. It pins to the nearest scrolling ancestor — inside AppShell that is <main>, not the window. Scroll the frame below.
Contacts
1,204 records. The header stays put while the list scrolls.
- Nguyễn Thị Mai
- Trần Quốc Bảo
- Lê Hoàng Anh
- Phạm Thu Hà
- Đỗ Minh Khôi
- Vũ Ngọc Lan
- Bùi Thanh Tùng
- Hoàng Kim Chi
- Đặng Văn Sơn
- Ngô Bích Ngọc
The blur is applied as supports-[backdrop-filter]:bg-background/80 on top of a solid bg-background: where the browser cannot blur, the header stays opaque rather than letting rows show through the title.
borderless is the other half of the pair. It removes the bottom rule and the pb-4 that goes with it — the header ends flush against its own content, so whatever follows needs to bring its own top spacing.
Overview
No rule under this one.
PageContainer widths
PageContainer exists so the left margin of Tasks and the left margin of CRM are the same number. Every page writing its own px-4 md:px-6 max-w-… drifts by a few pixels per screen — invisible on any one page, and exactly what makes a suite of products feel like a set of unrelated apps.
This preview frame is narrower than 64rem, so only sm visibly clamps here — md and lg start clamping and centring once the viewport is wide enough, while full never does at any width. The numbers above are the contract; pick by content, not by how it looks in this box.
as changes the wrapper element, and it is the one prop worth thinking about twice. Code only below — rendering a second <main> inside this documentation page would be exactly the landmark error the comment warns about.
import { PageContainer } from '@comitor/ui'
// Outside the shell — a print view, a 404, a standalone screen — there is no
// <main> on the page, so let the container be it.
//
// Inside AppShell, leave The two together
The header goes outside the container, as siblings in a fragment. That is deliberate: the header is full width, so its bottom rule and its sticky background span the whole content area, while the container clamps only the body. If you need the title to line up with a clamped column instead, put the header inside a <PageContainer flush> — you trade the edge-to-edge rule for the alignment.
Projects
Every project in the Acme workspace.
Static by design
Neither file carries 'use client'. Two consequences worth knowing:
- A list page can stay a Server Component: metadata, the frame and the data preparation on the server, with
'use client'pushed down to the one island that actually needs state — usually the table. Putting the directive at the top of the page instead ships the entire frame to the browser for nothing. - The breadcrumb arrives through props rather than being read from shell context. If
PageHeaderdepended onShellContext, every screen rendered outside the shell — a print view, an error page, a full-screen dialog — would throw.
Anything interactive you hand to actions or children still has to be a client component itself — the slots are transparent, they do not change the boundary.
Props
PageHeader
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | React.ReactNode | — | Rendered into the page's <h1>. The heading carries truncate, so a long title is ellipsized on one line instead of wrapping into the actions row. Because the interface is Omit<ComponentProps<'header'>, 'title'>, this prop replaces the native title attribute — you cannot set a tooltip through it. |
description | React.ReactNode | — | Muted supporting line under the title. Omitted entirely when not passed — no empty paragraph is left behind. |
icon | IconComponent | — | The icon component itself (CalendarCheck), never an element (<CalendarCheck />). Rendered aria-hidden inside a 36px rounded chip. |
breadcrumb | PageHeaderBreadcrumbItem[] | [] | The trail above the title. An empty array renders no <nav> at all, so there is no cost to leaving it off. |
LinkComponent | ComponentType<PageHeaderLinkProps> | <a> | The link component used for clickable crumbs. Pass next/link in a Next app; without it every crumb is a full page load. It only has to accept href, className and children. |
actions | React.ReactNode | — | Right-hand slot for buttons and menus. Laid out as a shrink-0 flex-wrap row with gap-2, and it drops below the title on narrow screens (the title block is sm:flex-row). |
children | React.ReactNode | — | Secondary row under the title — route tabs, a filter bar, status chips. Rendered inside the header, above the bottom rule, with mt-3. |
sticky | boolean | false | Pins the header with sticky top-0 z-20 and a backdrop blur, falling back to a solid background where backdrop-filter is unsupported. It sticks to the nearest scrolling ancestor, which inside AppShell is <main>. |
borderless | boolean | false | Removes the bottom rule — and the pb-4 that comes with it, so the header ends immediately after its content. |
className | string | — | Merged last, so anything you pass wins over the built-in classes. |
...rest | Omit<React.ComponentProps<'header'>, 'title'> | — | Forwarded to the <header data-slot="page-header"> element. Nothing is forwarded to the inner breadcrumb. |
PageHeaderBreadcrumbItem — the shape of each entry in the breadcrumb array.
| Prop | Type | Default | Description |
|---|---|---|---|
labelrequired | string | — | The visible text of the crumb. |
href | string | — | Makes the crumb a link, rendered through LinkComponent. Leave it off for a static crumb. The last item is rendered static regardless of whether it has an href — it is the current page. |
PageContainer
| Prop | Type | Default | Description |
|---|---|---|---|
width | 'sm' | 'md' | 'lg' | 'full' | 'md' | Maximum content width: sm = max-w-2xl (42rem), md = max-w-5xl (64rem), lg = 90rem, full = no clamp. |
flush | boolean | false | Drops the vertical padding (py-4 md:py-6). Horizontal padding stays, so the left edge still lines up with the header. Use it when the page manages its own spacing — a kanban board, a full-height editor. |
as | 'div' | 'main' | 'section' | 'div' | The wrapper element. Set "main" only when the container really is the page’s main landmark; AppShell already renders one. |
className | string | — | Merged after the width class. Layout classes for the body (space-y-6, grid, …) belong here. |
...rest | React.ComponentProps<'div'> | — | Forwarded to the wrapper, which carries data-slot="page-container". |
Accessibility
- The root is a real
<header>element and the title is a real<h1>— one per screen. (Each demo on this page emits one, so this documentation page has several; that is a demo artefact, not a pattern to copy.) - The title is truncated visually with
truncate, not shortened in the DOM — the full string is still read out. Do not pre-truncate it yourself. - Breadcrumbs render as
<nav>wrapping an ordered list. The final crumb is a<span role="link" aria-disabled="true" aria-current="page">: deliberately not focusable, because a link to the page you are already on is a keyboard stop that leads nowhere, whilearia-currentstill names it as the current position. - The chevrons between crumbs are
role="presentation"andaria-hidden, so a screen reader hears the trail, not a string of “greater-than”. - The one Vietnamese string. The breadcrumb
<nav>is namedaria-label="Đường dẫn phân cấp"by the underlying primitive, andPageHeadertakes nolabelsprop —...restlands on the<header>, not on the breadcrumb. An English-only product that cares about the landmark name should leavebreadcrumboff and compose<Breadcrumb aria-label="Breadcrumb">above the header itself. - The wrapper of the icon chip carries
aria-hidden="true", which takes the whole chip — glyph included — out of the accessibility tree, so it never doubles the title. Your icon component itself is only ever handed aclassNamehere; unlike EmptyState,PageHeaderdoes not passaria-hiddendown. Either way, nothing may live in the icon alone. - Icon-only buttons in
actionsneed their ownaria-label— the header does not supply one, as the demo above shows. - Colour in the icon chip follows the three-role contract:
--app-accenttints the background only, and the glyph sits on it attext-foreground. Nevertext-app-accent: the accent-on-accent version measured 1.69:1 against the 3:1 that WCAG 1.4.11 requires for graphical objects. - A sticky header at
z-20can cover content that a fragment link jumps to. Give anchored targetsscroll-margin-topequal to the header height when your page uses in-page anchors. PageContainerdefaults to a plain<div>so it adds no landmark by accident. Only reach foras="main"outside the shell, where nothing else provides one.