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

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} 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>

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).
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.

children

Trailing action cluster — button trios, kebab menus, pagination controls. Right-aligned with an 8px gap.

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>

✗ 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. 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.