Welcome to Oshon · v1.0  ·  Now in public beta for enterprise teams Read the launch notes
NavigationUpdatedFreeWCAG 2.2 AA

Page Header

10 variants — Back / Browse / View Switcher / Status / CTA.

Preview

Live preview
@oshon-ai/components

Audit log · trader-l2 policy

Last 7 days · 1,243 events

Livev2.3.0

Installation

Install the runtime packages:

pnpm
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives

Or scaffold the component source directly into your codebase (shadcn-style):

pnpm
pnpm dlx @oshon-ai/cli add pageheader

Wire the tokens into your Tailwind v4 stylesheet:

css
/* app/globals.css */
@import 'tailwindcss';
@import '@oshon-ai/tokens/css';
@import '@oshon-ai/tokens/tailwind';

New here? Walk through the full setup — prereqs, theming, your first render.

Usage

Import the component and render it. Every component supports the standard tier, size, and disabled props where applicable.

tsx
'use client';
import { PageHeader } from '@oshon-ai/components';

export default function Example() {
  return <PageHeader />;
}

Default — full-width, with actions

Page title

tsx
<PageHeader title="Page title" onBack={() => {}}>
      <TrailingActions />
    </PageHeader>

With subtitle

Page title

Secondary line of context

tsx
<PageHeader
      title="Page title"
      subtitle="Secondary line of context"
      onBack={() => {}}
    >
      <ButtonHug tier="tertiary">Cancel</ButtonHug>
      <ButtonHug tier="primary">Save</ButtonHug>
    </PageHeader>

With multiple tags

Page title

Tag ATag BTag C
tsx
<PageHeader
      title="Page title"
      onBack={() => {}}
      tags={
        <>
          <Badge state="success">Tag A</Badge>
          <Badge state="info">Tag B</Badge>
          <Badge state="warning">Tag C</Badge>
        </>
      }
    >
      <ButtonHug tier="primary">Action</ButtonHug>
    </PageHeader>

Page title

tsx
{
    function Demo() {
      const [q, setQ] = useState('');
      return (
        <PageHeader
          title="Page title"
          filters={
            <Input
              placeholder="Search"
              value={q}
              onChange={(e) => setQ(e.target.value)}
              style={{ maxWidth: 360 }}
            />
          }
        >
          <ButtonHug tier="secondary">Secondary</ButtonHug>
          <ButtonHug tier="primary">Primary</ButtonHug>
        </PageHeader>
      );
    }
    return <Demo />;
  }

With filter chips

Page title

tsx
{
    function Demo() {
      const [active, setActive] = useState('all');
      const opts = ['all', 'recent', 'archived', 'shared'] as const;
      return (
        <PageHeader
          title="Page title"
          filters={
            <>
              {opts.map((id) => (
                <ChoiceChip
                  key={id}
                  selected={active === id}
                  onClick={() => setActive(id)}
                >
                  {id.charAt(0).toUpperCase() + id.slice(1)}
                </ChoiceChip>
              ))}
            </>
          }
        >
          <ButtonHug tier="primary">Primary</ButtonHug>
        </PageHeader>
      );
    }
    return <Demo />;
  }

Full toolbar — every slot populated

Page title

Secondary line of context

Tag ATag B
tsx
{
    function Demo() {
      const [q, setQ] = useState('');
      return (
        <PageHeader
          title="Page title"
          subtitle="Secondary line of context"
          onBack={() => {}}
          tags={
            <>
              <Badge state="success">Tag A</Badge>
              <Badge state="info">Tag B</Badge>
            </>
          }
          filters={
            <Input
              placeholder="Search"
              value={q}
              onChange={(e) => setQ(e.target.value)}
              style={{ maxWidth: 320 }}
            />
          }
        >
          <TrailingActions />
        </PageHeader>
      );
    }
    return <Demo />;
  }

Size matrix — xs / s / m / l / mobile

Page title

Secondary line of context

Tag

Page title

Secondary line of context

Tag

Page title

Secondary line of context

Tag

Page title

Secondary line of context

Tag

Page title

Secondary line of context

Tag
tsx
<div style={{ display: 'flex', flexDirection: 'column' }}>
      {SIZES.map((size) => (
        <PageHeader
          key={size}
          size={size}
          title="Page title"
          subtitle="Secondary line of context"
          onBack={() => {}}
          tags={
            <Badge size={size === 'mobile' ? 'm' : size} state="info">
              Tag
            </Badge>
          }
        >
          <TrailingActions />
        </PageHeader>
      ))}
    </div>

Top-level page (no back arrow)

Page title

tsx
<PageHeader title="Page title">
      <ButtonHug tier="secondary">Secondary</ButtonHug>
      <ButtonHug tier="primary">Primary</ButtonHug>
    </PageHeader>

Mobile — stacked layout

Page title

Secondary line

Tag
tsx
<div style={{ maxWidth: 420 }}>
      <PageHeader
        size="mobile"
        title="Page title"
        subtitle="Secondary line"
        onBack={() => {}}
        tags={<Badge state="success">Tag</Badge>}
        filters={
          <Input placeholder="Search" style={{ width: '100%' }} />
        }
      >
        <ButtonHug tier="tertiary" size="s">
          Cancel
        </ButtonHug>
        <ButtonHug tier="primary" size="s">
          Save
        </ButtonHug>
      </PageHeader>
    </div>

API

Every prop is documented here directly from the component's TypeScript interface. Inherited DOM attributes (aria-*, onClick, style, etc.) work as usual but are omitted from this table.

PropTypeDefaultDescription
title*ReactNodePage title. Accepts a ReactNode so callers can pass breadcrumb or heading compositions, but the canonical usage is a plain string rendered as an `<h1>`.
asenumheaderRoot element. Defaults to `'header'` (banner landmark for a page). Drop to `'div'` when PageHeader is used as a section toolbar inside a page that already has a banner landmark.
backIconReactNodeOverride for the back-button glyph. Defaults to a 16×16 ArrowLeft. Use this for RTL flips or to swap in a custom icon. The icon renders inside a fixed 20×20 button shell so the click target stays consistent.
backLabelstringGo backi18n-friendly aria-label for the back button. Default `'Go back'`.
badgeReactNode@deprecated Use `tags` instead. Kept for pre-1.0 migration — any value passed here flows into the tag cluster slot.
childrenReactNodeTrailing action cluster — button trios, kebab menus, pagination controls. Right-aligned with an 8px gap (Figma parity). At `size="mobile"` this row sits at the bottom of the stacked layout.
classNamestringAdditional classes merged onto the root element.
filtersReactNodeOptional middle slot for page-level filters: search inputs, date range pickers, filter chips, segmented controls. Takes `flex-1` so it absorbs the remaining horizontal space between the lead cluster and the trailing action cluster.
headingLevelenum1HTML heading level for the title. Defaults to `1` — the typical page-level heading. Use a deeper level when PageHeader is rendered inside a page that already has an `<h1>`.
onBack((event: MouseEvent<HTMLButtonElement, MouseEvent>) => void)Optional callback fired when the leading back arrow is clicked. When omitted, the back button does not render.
sizeenummVisual size. Default `'m'` (Figma parity).
slotClassNamePageHeaderSlotClassNamesPer-slot className overrides — see {@link PageHeaderSlotClassNames}.
subtitleReactNodeOptional secondary line shown directly beneath the title. Takes the muted on-surface color and a smaller font-size that scales alongside the title.
tagsReactNodeOptional cluster of status pills / keyword chips rendered adjacent to the title. Accepts one or many children; replaces the original single-badge convention. The legacy `badge` prop (below) flows into this same slot.

Styling

Three layers of customization, in order of escape-hatch strength: className overrides → data-attribute targeting → CSS custom properties.

Passing Tailwind classes

Every Oshon component accepts a className prop merged AFTER the component's default classes. Use it to override spacing, color, or size without forking the component.

tsx
<PageHeader
  className="ring-2 ring-offset-2 ring-blue-500"
/>

Data attributes

Oshon components expose their internal state as data-oshon-* attributes so you can target them from CSS without coupling to internal class names. The most common attributes are listed below — see the component's source for the full set.

AttributeValuesDescription
data-oshon-sizexs · s · m · l · mobileVisual size axis. Mirrors the `size` prop.
data-oshon-tierprimary · secondary · tertiaryVisual emphasis tier (Button family). Mirrors the `tier` prop.
data-oshon-stateenabled · active · error · disabledComponent surface state. Set automatically based on props.
data-disabledtrue · (omitted)Set when `disabled` is true. Pair with `:disabled` CSS for native input components.
data-stateopen · closed · checked · unchecked · …Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.).
css
/* Target the secondary tier specifically */
[data-oshon-tier="secondary"] {
  --oshon-color-primary-700: var(--my-brand-color);
}

Interactive states

Every interactive component supports the standard CSS pseudo- classes plus Tailwind's state variants. Focus rings always use :focus-visible so keyboard users see them but mouse users don't.

  • :hover / hover:* — pointer hover
  • :focus-visible / focus-visible:* — keyboard focus
  • :active / active:* — pressed
  • :disabled / disabled:* — set via the disabled prop

Anatomy

The named regions a consumer composes when rendering this component. Each is documented separately so you can target keyboard nav, ARIA labels, and slot props with precision.

title

Page title. Accepts a ReactNode but the canonical usage is a short string rendered as an `<h1>`. Font is Lato Bold; size scales per the five-size axis (14/18 at xs through 20/24 at l).

subtitle

Optional secondary line directly beneath the title. Rendered as a `<p>` in the muted on-surface color at a smaller size (12/16 at xs through 14/20 at l).

tags

Optional cluster of status pills / keyword chips next to the title. Accepts one or many children; they render inline with a 4px gap and wrap when the row is tight. Replaces the original single-badge convention (the `badge` prop is a deprecated alias that flows into this slot).

filters

Middle slot for search inputs, date range pickers, filter chips, segmented controls. Takes `flex-1` so it absorbs the remaining horizontal space between the lead cluster and the trailing actions. On mobile this slot drops onto its own full-width row above the actions.

children

Trailing action cluster — button trios, kebab menus, pagination controls. Right-aligned with an 8px gap. On mobile this row sits at the bottom of the stacked layout.

Keyboard

Renders a `<header>` landmark. Tab order: back button (when `onBack` is provided) → any interactive element inside `tags` → any interactive element inside `filters` → any interactive element inside `children`, in DOM order. The back button is a native `<button type="button">` with `aria-label` = `backLabel` (default "Go back"). Title is rendered as the heading level chosen via `headingLevel` (default `h1`). Subtitle renders as a `<p>` immediately after the heading inside the same title column so screen readers announce title-then-subtitle as a single labelled region.

Accessibility

Every Oshon component ships axe-clean. We test in CI on every PR and publish the audit log per component.

WCAG level
2.2 AA
Screen readers tested
VoiceOver (macOS), NVDA (Windows)
Last axe audit
2026-04-24

Do / Don't

✓ Do

Basic page header
<PageHeader title="Page title" />
Toolbar — title, subtitle, tags, search, actions
<PageHeader
  title="Page title"
  subtitle="Secondary line of context"
  onBack={() => router.back()}
  tags={<>
    <Badge state="success">Tag A</Badge>
    <Badge state="info">Tag B</Badge>
  </>}
  filters={<TextField placeholder="Search" leadingIcon="search" />}
>
  <ButtonHug tier="tertiary">Cancel</ButtonHug>
  <ButtonHug tier="primary">Save</ButtonHug>
</PageHeader>
Index page — filter chips + primary CTA
<PageHeader
  title="Page title"
  filters={<>
    <ChoiceChip>All</ChoiceChip>
    <ChoiceChip>Recent</ChoiceChip>
    <ChoiceChip>Archived</ChoiceChip>
  </>}
>
  <ButtonHug tier="primary">Primary action</ButtonHug>
</PageHeader>
Mobile stacked layout
<PageHeader
  size="mobile"
  title="Page title"
  subtitle="Secondary line"
  onBack={() => router.back()}
  tags={<Badge state="success">Tag</Badge>}
  filters={<TextField placeholder="Search" />}
>
  <ButtonHug tier="primary" size="s">Save</ButtonHug>
</PageHeader>

✗ Don't

Using PageHeader as a global banner
<PageHeader title="Oshon" /> /* at the top of the app */

PageHeader is scoped to a single page — it renders a `<header>` landmark with the page title. For the app-wide brand bar use `GlobalToolbar`, which owns the banner landmark and the brand / search / notification slots.

Rendering two `<h1>` headings on a page
<><PageHeader title="Section A" /><PageHeader title="Section B" /></>

WCAG 2.2 AA expects a single top-level heading per page. Drop the second PageHeader to `headingLevel={2}` (or use a different section header) so the document outline stays valid.

Hardcoding a width or height
<PageHeader className="h-[80px] w-[1200px]" title="Page title" />

PageHeader is fluid — it fills the width of its parent and treats `min-height` as a floor that grows to fit content (subtitles, wrapped filters). Forcing pixel dimensions breaks principle #2 (five sizes) and desyncs PageHeader from an adjacent GlobalToolbar at non-default sizes.

Stuffing free-form copy into the filters slot
<PageHeader title="Page title" filters="42 results · last sync 2 min ago" />

The filters slot is for interactive controls (search, chips, range pickers). Render result counts and last-sync timestamps in the page body, not the toolbar, so the toolbar height stays predictable across data states.

Design rationale

PageHeader started as a Figma-faithful title bar with three slots (title, badge, children). Product feedback revealed that real pages want more on this surface: a subtitle for context, multiple tags rather than a single status pill, and a place to put page-level controls (search, filter chips, date range pickers) without colliding with the trailing action cluster. The extended slot topology — lead (back + title-column + tags), filters (flex-1), end (actions) — keeps the original Figma geometry (56px row, 16/8 padding, 16px gap, 20×20 back button at node 9336:1131) intact while giving every consumer a predictable place to land each kind of content. The title-column composes title + subtitle vertically so the heading-then-supporting-text reading order matches the visual order. The tags slot accepts plural children with a wrap-friendly gap so a row with five tags degrades gracefully on a narrow viewport. The filters slot takes `flex-1` and `min-w-0` so an inline search field grows to absorb available width and shrinks past its content size when the surrounding clusters need room. The mobile variant stacks three rows rather than three nested groups so each row owns its own width and gap rules — keeps the layout debuggable and prevents the filters slot from collapsing into the actions row at narrow widths. Configurability layers (slotClassName per region, backIcon override, as=`header|div|section`, headingLevel 1..6) ship as additive APIs so callers never need to fork the component — every layout decision the component owns is reachable from props. The badge prop is kept as a deprecated alias because zero consumers shipped against the original API in this monorepo, but the alias means we never have to chase down call sites in downstream products.