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.

The content block owns the gap now.

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.

width="sm" · max-w-2xl · 42rem
width="md" · max-w-5xl · 64rem · default
width="lg" · 90rem
width="full" · max-w-none

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.

printable-invoice.tsx
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.

Page body — the table, form or board goes here.

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 PageHeader depended on ShellContext, 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

PropTypeDefaultDescription
titlerequiredReact.ReactNodeRendered 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.
descriptionReact.ReactNodeMuted supporting line under the title. Omitted entirely when not passed — no empty paragraph is left behind.
iconIconComponentThe icon component itself (CalendarCheck), never an element (<CalendarCheck />). Rendered aria-hidden inside a 36px rounded chip.
breadcrumbPageHeaderBreadcrumbItem[][]The trail above the title. An empty array renders no <nav> at all, so there is no cost to leaving it off.
LinkComponentComponentType<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.
actionsReact.ReactNodeRight-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).
childrenReact.ReactNodeSecondary row under the title — route tabs, a filter bar, status chips. Rendered inside the header, above the bottom rule, with mt-3.
stickybooleanfalsePins 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>.
borderlessbooleanfalseRemoves the bottom rule — and the pb-4 that comes with it, so the header ends immediately after its content.
classNamestringMerged last, so anything you pass wins over the built-in classes.
...restOmit<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.

PropTypeDefaultDescription
labelrequiredstringThe visible text of the crumb.
hrefstringMakes 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

PropTypeDefaultDescription
width'sm' | 'md' | 'lg' | 'full''md'Maximum content width: sm = max-w-2xl (42rem), md = max-w-5xl (64rem), lg = 90rem, full = no clamp.
flushbooleanfalseDrops 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.
classNamestringMerged after the width class. Layout classes for the body (space-y-6, grid, …) belong here.
...restReact.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, while aria-current still names it as the current position.
  • The chevrons between crumbs are role="presentation" and aria-hidden, so a screen reader hears the trail, not a string of “greater-than”.
  • The one Vietnamese string. The breadcrumb <nav> is named aria-label="Đường dẫn phân cấp" by the underlying primitive, and PageHeader takes no labels prop — ...rest lands on the <header>, not on the breadcrumb. An English-only product that cares about the landmark name should leave breadcrumb off 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 a className here; unlike EmptyState, PageHeader does not pass aria-hidden down. Either way, nothing may live in the icon alone.
  • Icon-only buttons in actions need their own aria-label — the header does not supply one, as the demo above shows.
  • Colour in the icon chip follows the three-role contract: --app-accent tints the background only, and the glyph sits on it at text-foreground. Never text-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-20 can cover content that a fragment link jumps to. Give anchored targets scroll-margin-top equal to the header height when your page uses in-page anchors.
  • PageContainer defaults to a plain <div> so it adds no landmark by accident. Only reach for as="main" outside the shell, where nothing else provides one.