Page Header
A page-level header with title and optional description.
Installation
$
Usage
import { PageHeader } from "@/components/ui/page-header"Examples
Default
The title and description at the top of a page. It ships no bottom margin: put it in a Stack and let the container own the gap to whatever follows.
Dashboard
Overview of your workspace
With an action
A control seated on the heading's right, for the one control the section is about: a view switcher, a range picker. The title wraps rather than pushing the control off the end. Filters and table-wide actions are a different row and belong in TableToolbar below.
Subscriber growth
Signups over the last 30 days.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| title* | string | - | The page title. |
| description | string | - | Optional supporting text below the title. |
| action | ReactNode | - | A control seated on the heading's right, top-aligned to the title's cap. For the one control the section is about; filters and table-wide actions belong in TableToolbar under the heading instead. |
| size | "sm" | "md" | "lg" | "lg" | Ramp keyed to the type scale. sm: title text-sm, description text-1xs, 8px below. md: title text-lg, description text-sm, 16px below. lg: title text-2xl, description text-base, 24px below. The below gap only applies when children are present. |
| as | "h1" | "h2" | "h3" | "h1" | Heading element. A page has one h1; a section-level header should pass as="h2". |
| children | ReactNode | - | Content below the heading group. When present, PageHeader renders as a flex column and owns the gap between the group and this content via the size's below-gap token. |
import { cn } from "@/registry/lib/utils"
const PAGE_HEADER_SIZE = {
sm: {
title: "text-sm",
description: "text-1xs",
below: "var(--spacing-rhythm-heading-sm)",
},
md: {
title: "text-lg",
description: "text-base",
below: "var(--spacing-rhythm-heading-md)",
},
lg: {
title: "text-2xl",
description: "text-base",
below: "var(--spacing-rhythm-heading-lg)",
},
} as const
type PageHeaderSize = keyof typeof PAGE_HEADER_SIZE
interface PageHeaderProps extends Omit<React.ComponentProps<"div">, "children"> {
title: string
description?: string
size?: PageHeaderSize
as?: "h1" | "h2" | "h3"
/** A control that belongs TO this heading, seated on its right: a view
* switcher, a range picker, the one action the section is for. Filters and
* table-wide actions are a different row and belong in `TableToolbar` under
* the heading, bound to the thing they scope rather than to the title. */
action?: React.ReactNode
children?: React.ReactNode
}
// @use-when a heading group: a title with an optional description, where the
// gap below ramps with the title's size.
export function PageHeader({
title,
description,
size = "lg",
as: Heading = "h1",
action,
className,
style,
children,
...props
}: PageHeaderProps) {
const scale = PAGE_HEADER_SIZE[size]
const group = (
<div
// `flex-1 min-w-0` only under an action, so the group claims the row and
// pushes the control to the far edge, and so a long title wraps instead
// of shoving the control off the end. Without an action the wrapper is
// the whole header and neither is wanted.
className={cn("flex flex-col", action && "min-w-0 flex-1")}
style={{ gap: "var(--spacing-rhythm-trim-pair)" }}
>
<Heading className={cn(scale.title, "cap-trim leading-snug font-medium text-pretty break-words")}>{title}</Heading>
{description && (
<p className={cn(scale.description, "leading-normal text-muted-foreground text-pretty break-words")}>
{description}
</p>
)}
</div>
)
// `items-start`, never `items-center`: the title carries `cap-trim`, so its
// box is the cap height and centring against it drops the control visibly
// below the text it belongs to. Start-aligned, the control's top edge meets
// the cap top, which is the line the eye actually reads the heading from.
// The row gap is the `cluster` rung in its arbitrary-property form, so it
// stays a real `var()`; a numbered utility would freeze the length at compile
// time and no token override could reach it again.
const head = action ? (
<div className="flex items-start gap-(--spacing-rhythm-cluster)">
{group}
<div data-slot="page-header-action" className="shrink-0">
{action}
</div>
</div>
) : (
group
)
if (!children) {
return (
<div data-slot="page-header" className={cn(className)} style={style} {...props}>
{head}
</div>
)
}
return (
<div
data-slot="page-header"
className={cn("flex flex-col", className)}
style={{ ...style, gap: scale.below }}
{...props}
>
{head}
{children}
</div>
)
}
// Gap below the heading group is set via inline style reading the token, not a
// gap-* class: Tailwind inlines spacing utility values at compile time, so a
// class would freeze the length and a dial or token override could never reach
// it (same reason registry/ui/stack.tsx applies its gap this way).
//
// The title carries `cap-trim` and the group therefore reads
// `--spacing-rhythm-trim-pair`, NOT `--spacing-rhythm-pair`. Those two rungs
// exist because a trimmed box and an untrimmed one need different numbers to
// paint a comparable gap: trimming removes the half-leading a plain text box
// supplies on both sides, so the token has to state outright what it used to
// inherit, and it also measures the bottom edge at the BASELINE, so a descender
// in the title paints into the gap. See stack.tsx for the full derivation of
// the 12px. The pairing is the invariant - `cap-trim` on the heading and
// `trim-pair` on its container always travel together, and `verify-ui`'s
// `trim-rung-mismatch` rule fails the build if one appears without the other.
export { PAGE_HEADER_SIZE, type PageHeaderSize }