ObjectUIObjectUI
Layout

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/spec build this repo installs, page:header is the only spelling the contract knows: it is the PageComponentType value, and it is the ComponentPropsMap row that binds PageHeaderProps — whose own rejection messages name the surface page:header. Neither page-header nor layout:page-header appears in either place.

The alias is still accepted — not retired, and not silently dropped. registerLayout() registers page-header (plus its namespaced form layout:page-header), so a node written that way renders today: it resolves to the @object-ui/layout component 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 its type through ComponentPropsMap, page-header has no row there, and PageComponentSchema.properties is an open z.record(z.string(), z.unknown()) bag — so a misspelled or retired key written under the alias is neither rejected nor reported. Under page:header the 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, action and showBack are this component's own, and icon is 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

Users

Manage your team members and permissions

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 childrenactions → 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 key

A 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-2xl on mobile, text-3xl on desktop
  • Font weight: font-bold
  • Tracking: tracking-tight

Subtitle

  • Font size: text-sm
  • Color: text-muted-foreground (gray)

Container

  • Padding bottom: pb-4 at every breakpoint — there is no responsive variant
  • Border: border-b along the bottom edge
  • Gap: gap-3 on the outer column; the title row itself uses gap-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)

On this page