Preview
Audit log · trader-l2 policy
Last 7 days · 1,243 events
Installation
Install the runtime packages:
pnpm add @oshon-ai/components @oshon-ai/tokens @oshon-ai/primitives
Or scaffold the component source directly into your codebase (shadcn-style):
pnpm dlx @oshon-ai/cli add pageheader
Wire the tokens into your Tailwind v4 stylesheet:
/* 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.
'use client';
import { PageHeader } from '@oshon-ai/components';
export default function Example() {
return <PageHeader />;
}Default — full-width, with actions
Page title
<PageHeader title="Page title" onBack={() => {}}>
<TrailingActions />
</PageHeader>With subtitle
Page title
Secondary line of context
<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
<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>With inline search
Page title
{
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
{
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
{
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
Page title
Secondary line of context
Page title
Secondary line of context
Page title
Secondary line of context
Page title
Secondary line of context
<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
<PageHeader title="Page title">
<ButtonHug tier="secondary">Secondary</ButtonHug>
<ButtonHug tier="primary">Primary</ButtonHug>
</PageHeader>Mobile — stacked layout
Page title
Secondary line
<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.
| Prop | Type | Default | Description |
|---|---|---|---|
title* | ReactNode | — | Page title. Accepts a ReactNode so callers can pass breadcrumb or heading compositions, but the canonical usage is a plain string rendered as an `<h1>`. |
as | enum | header | Root 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. |
backIcon | ReactNode | — | Override 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. |
backLabel | string | Go back | i18n-friendly aria-label for the back button. Default `'Go back'`. |
badge | ReactNode | — | @deprecated Use `tags` instead. Kept for pre-1.0 migration — any value passed here flows into the tag cluster slot. |
children | ReactNode | — | Trailing 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. |
className | string | — | Additional classes merged onto the root element. |
filters | ReactNode | — | Optional 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. |
headingLevel | enum | 1 | HTML 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. |
size | enum | m | Visual size. Default `'m'` (Figma parity). |
slotClassName | PageHeaderSlotClassNames | — | Per-slot className overrides — see {@link PageHeaderSlotClassNames}. |
subtitle | ReactNode | — | Optional secondary line shown directly beneath the title. Takes the muted on-surface color and a smaller font-size that scales alongside the title. |
tags | ReactNode | — | Optional 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.
<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.
| Attribute | Values | Description |
|---|---|---|
data-oshon-size | xs · s · m · l · mobile | Visual size axis. Mirrors the `size` prop. |
data-oshon-tier | primary · secondary · tertiary | Visual emphasis tier (Button family). Mirrors the `tier` prop. |
data-oshon-state | enabled · active · error · disabled | Component surface state. Set automatically based on props. |
data-disabled | true · (omitted) | Set when `disabled` is true. Pair with `:disabled` CSS for native input components. |
data-state | open · closed · checked · unchecked · … | Radix-derived state for overlay components (Dialog, Tabs, Toggle, etc.). |
/* 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 thedisabledprop
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.
titlePage 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).
subtitleOptional 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).
tagsOptional 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).
filtersMiddle 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.
childrenTrailing 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
<PageHeader title="Page title" />
<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><PageHeader
title="Page title"
filters={<>
<ChoiceChip>All</ChoiceChip>
<ChoiceChip>Recent</ChoiceChip>
<ChoiceChip>Archived</ChoiceChip>
</>}
>
<ButtonHug tier="primary">Primary action</ButtonHug>
</PageHeader><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
<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.
<><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.
<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.
<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.