PageHeader
Page header component with title, subtitle, and actions
Canonical author key:
page:header. This page's key,page-header, is a legacy alias — and it resolves to a different renderer.Measured against the
@objectstack/specbuild this repo installs,page:headeris the only spelling the contract knows: it is thePageComponentTypevalue, and it is theComponentPropsMaprow that bindsPageHeaderProps— whose own rejection messages name the surfacepage:header. Neitherpage-headernorlayout:page-headerappears in either place.The alias is still accepted — not retired, and not silently dropped.
registerLayout()registerspage-header(plus its namespaced formlayout:page-header), so a node written that way renders today: it resolves to the@object-ui/layoutcomponent documented below, which is also what the demo on this page renders. What the alias does not carry is a contract. A node's props are validated by dispatching itstypethroughComponentPropsMap,page-headerhas no row there, andPageComponentSchema.propertiesis an openz.record(z.string(), z.unknown())bag — so a misspelled or retired key written under the alias is neither rejected nor reported. Underpage:headerthe same key is refused by name.Author metadata pages against
page:header. The canonical node is the record-aware renderer in@object-ui/components; Slotted pages shows it in place. Its props are not the props below —icon,actionandshowBackare this component's own, andiconis retired on the canonical node (Component Props, below). Everything on this page — Props, Styling, Examples, demo — is measured against this component, the alias's renderer; converting the page to the canonical node is tracked in objectui#3906.
The PageHeader component provides a consistent header for pages with title, subtitle, and action buttons.
Basic Usage
import { PageHeader } from '@object-ui/layout';
<PageHeader
title="Dashboard"
subtitle="Welcome to your dashboard"
/>With Actions
Pageheader With Actions
Component Props
interface PageHeaderComponentProps extends React.HTMLAttributes<HTMLDivElement> {
title: string; // Page title (required); {field.path} tokens interpolated
subtitle?: string; // Secondary line under the title — the canonical key
icon?: React.ReactNode | string; // Lucide icon name, or a pre-rendered node
action?: React.ReactNode; // Action buttons or elements (right-aligned slot)
actions?: unknown[]; // ActionDef list (or action ids) → record:quick_actions
showBack?: boolean; // Back arrow; inferred from record context when omitted
schema?: { children?: unknown[]; actions?: unknown[]; properties?: { actions?: unknown[] } };
children?: React.ReactNode; // Additional content (right-aligned slot)
className?: string; // Additional CSS classes
}subtitle is the canonical key: @objectstack/spec/ui's PageHeaderProps — the contract
for the authored page:header node — declares title / subtitle / breadcrumb / actions / recordChrome / showStar / showCopyId / maxVisible / mobileMaxVisible / aria and has no
description. Both title and subtitle interpolate {field.path} tokens against the
surrounding record context.
maxVisible and mobileMaxVisible are the canonical node's action-overflow budget, and
they belong to that contract rather than to this component: the page:header renderer in
@object-ui/components reads them, and @object-ui/layout's PageHeader — the component
documented above — does not. The shape describes them as "How many header actions render as
inline buttons before the rest fold into the overflow menu (renderer default 3)" and "The
maxVisible budget on mobile viewports (renderer default 1)";
Slotted pages shows them authored on a page.
icon is deliberately not in that list, and the distinction matters because this page
documents a React component while the spec governs an authored node. PageHeaderProps.icon
was retired from @objectstack/spec (objectstack#6946, ADR-0087 D2): the canonical
page:header renderer never read it, so an authored value used to be accepted and dropped,
and the contract now rejects the key by name. On a canonical page:header node the
header's identity comes from the record chrome (recordChrome, on by default) plus each
action's own icon; os migrate meta --from 16 rewrites stored sources.
The icon prop listed above is this component's own capability, not that spec key.
PageHeader from @object-ui/layout — registered as page-header and
layout:page-header — really does draw it (a string resolves through LazyIcon, a React
node renders as-is), which is why it stays on that registration's inputs and why the demo
on this page writes "icon": "users" (objectui#3829). Do not carry the key over to a
canonical page:header node: the same JSON renders here and fails to parse there.
description used to be a legacy alias of subtitle that this component read as
well. It is retired (objectui#3789): the prop is gone and the renderer draws a
secondary line only for subtitle.
Nothing is lost by that, because the normalization moved upstream where it belongs. The
ADR-0087 D2 conversion entry page-header-subtitle-alias is live in protocol 17: the
loader rewrites a header node's properties.description to properties.subtitle as the
stack is loaded, so a page stored with the old key still renders its second line. An
already-canonical subtitle wins, and a description it shadows is left exactly as
authored rather than the loader choosing between two different second lines.
The retirement waited on a measurement rather than a date: every position a page-header
node can occupy had to be shown to pass through that rewrite. It did not, at first — the
conversion walk reached only regions[].components[], leaving slots.header and
container-nested headers unconverted (objectstack#6775). With that walk widened
(objectstack#6776 for slots, PR #7034 for containers nested to any depth), all seven
positions convert; packages/layout/src/__tests__/page-header-subtitle-conversion-coverage.test.ts
re-measures them on every run against the spec build this repo consumes.
If you are hand-feeding schema JSON to the renderer without going through an ObjectStack
loader, write subtitle — that is the only spelling that renders on every path. Stored
metadata is migrated in place by os migrate meta.
actions is delegated to the record:quick_actions widget with
location: 'record_header', so authors get the standard toolbar (icons, overflow menu,
permission filtering, confirm dialogs) without declaring a sibling node. Precedence for
the right-aligned slot is action → React children → actions → schema children.
showBack renders a back arrow that navigates one level up the URL. When omitted it
defaults to true on record pages (a recordId in scope) that are not embedded in a
drawer or modal, and false otherwise.
Gating a header action on a relation field
Each header action's visible (and hidden / disabled) is a CEL predicate evaluated
against the current record. The most common thing it is asked to express — "only the
record's manager sees this button" — is written against a lookup / master_detail /
user / tree field, and there is exactly one supported spelling for it:
actions:
- name: escalate
label: Escalate
locations: [record_header]
visible: os.user.id == record.manager # ✅ the relation IS its foreign keyA relation field binds as the id the server stores, never as the record it points at. That is true here regardless of whether this surface expanded the relation for display — the record page expands the relations it renders, and the predicate scope collapses them back before evaluating, so one predicate reaches one verdict on every surface and matches what the same expression means in a server-side rule.
The consequence is that record.manager.id is not a second spelling of the same
thing — it faults, because record.manager is a string:
visible: os.user.id == record.manager.id # ❌ faults: no such key `id`A predicate that faults is resolved fail-closed — the action is not rendered, because a precondition that could not be checked is not a precondition that passed. It is never resolved silently: the console carries one warning per broken predicate, naming the action and quoting the expression, for example:
[object-ui] A conditional predicate failed to evaluate (page:header action "escalate"
visible) and was treated as its safe default: "os.user.id == record.manager.id".
Reason: [runtime] No such key: id. Check the field names and CEL syntax.An empty relation is not a fault: record.manager is null, the comparison is a
clean false, and the action is simply hidden with nothing logged.
The identity side of the comparison is os.user — the spec's canonical CEL identity
scope, and the same one the server binds — with current_user, user and ctx.user
available as aliases of it.
Examples
Simple Header
<PageHeader title="Settings" />With Subtitle
<PageHeader
title="Analytics"
subtitle="Track your key metrics and performance indicators"
/>With Action Buttons
<PageHeader
title="Projects"
subtitle="Manage your projects and tasks"
action={
<div className="flex gap-2">
<Button variant="outline">
<Download className="mr-2 h-4 w-4" />
Export
</Button>
<Button>
<Plus className="mr-2 h-4 w-4" />
New Project
</Button>
</div>
}
/>With Children
<PageHeader
title="Team"
subtitle="Manage your team members"
>
<Button>Invite Member</Button>
<Button variant="outline">Settings</Button>
</PageHeader>Layout
The PageHeader uses a flex layout:
┌─────────────────────────────────────┐
│ Title [Actions] │
│ Subtitle │
└─────────────────────────────────────┘- Left Side: Title and subtitle stacked vertically
- Right Side: Action buttons or custom content
- Responsive: Actions move below title on mobile (if needed)
Styling
Title
- Font size:
text-2xlon mobile,text-3xlon desktop - Font weight:
font-bold - Tracking:
tracking-tight
Subtitle
- Font size:
text-sm - Color:
text-muted-foreground(gray)
Container
- Padding bottom:
pb-4at every breakpoint — there is no responsive variant - Border:
border-balong the bottom edge - Gap:
gap-3on the outer column; the title row itself usesgap-x-4 gap-y-2
Usage with Page Component
The Page component includes built-in PageHeader support:
<Page
title="Dashboard"
description="Welcome to your dashboard"
action={<Button>Add Widget</Button>}
body={[
// Page content
]}
/>Note that description here is the Page component's own prop, not the header's — it
renders the page's prose under the page title and is a declared input of the page
renderer. It is unrelated to the description alias retired above: that one was a second
spelling of the header's subtitle, this one is the page node's own live key. Do not
rename it to subtitle, and do not assume objectui#3789 touched it.
Complete Example
import { PageHeader } from '@object-ui/layout';
import { Button } from '@object-ui/components';
import { Plus, Download, Settings } from 'lucide-react';
function UsersPage() {
return (
<div>
<PageHeader
title="Team Members"
subtitle="Manage your team members and their roles"
action={
<div className="flex items-center gap-2">
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
<Button variant="outline" size="sm">
<Settings className="mr-2 h-4 w-4" />
Settings
</Button>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Invite Member
</Button>
</div>
}
/>
{/* Page content */}
<div className="space-y-4">
{/* Table, cards, etc. */}
</div>
</div>
);
}Accessibility
The PageHeader includes:
- Semantic HTML (
<h1>for title) - Proper heading hierarchy
- Color contrast for text
- Responsive layout
Responsive Behavior
- Mobile: Actions stack below title if needed
- Desktop: Actions align to right side
- Spacing: Identical at every breakpoint — there is no responsive padding variant; the only breakpoint-driven change is the title's font size (see Styling → Title)
Related
- Page - Complete page layout
- AppShell - Application shell
- SidebarNav - Sidebar navigation