Display

Page Header

The app-shell page header — breadcrumb slot, icon, title, status meta, actions and an optional controlled tab strip, all responsive to a narrow container.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export interface PageHeaderTab {
  value: string
  label: string
  /** Rendered as a small muted pill after the label. */
  badge?: number | string
  disabled?: boolean
}

export interface PageHeaderProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/page-header.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "PageHeader" component.

Contract
- Export a forwardRef<HTMLElement, PageHeaderProps> rendering a <header>;
  spread remaining native header props on the root, merge className via cn().
- Props: title (string, required), description?: ReactNode, icon?: ReactNode,
  breadcrumb?: ReactNode (slot — the consumer drops in their own breadcrumb
  component here, e.g. this registry's Breadcrumbs), meta?: ReactNode
  (rendered inline right after the title — status pills, version tags),
  actions?: ReactNode (top-right action area), tabs?: { value: string;
  label: string; badge?: number | string; disabled?: boolean }[], value?:
  string (controlled selected tab), onValueChange?: (value: string) => void,
  border?: boolean (default true), sticky?: boolean (default false), size?:
  "sm" | "md" (default "md"), titleAs?: "h1" | "h2" | "h3" (default "h1").

Behavior
- Title row layout: icon + title + meta form one flex item on the left,
  actions form a second flex item on the right, both inside a flex-wrap row.
  The left item is flex-1 min-w-0 (it has no floor — it shrinks first); the
  actions item keeps its natural content size. Once actions no longer fit
  next to the shrunk-to-minimum title, the whole actions block wraps to its
  own line below the title row — it never squeezes into a sliver, and the
  title never gets pushed into overflow.
- title renders as line-clamp-2 (plus break-words) so an excessively long
  string clamps instead of growing the header indefinitely; description
  (when present) is also line-clamp-2 under the title row.
- Tabs render as a real APG tablist under the title row: role="tablist" +
  role="tab" per item, aria-selected on the active tab, roving tabindex
  (only the active — or first enabled — tab has tabIndex 0, every other tab
  is -1). ArrowRight/ArrowLeft move focus+selection to the next/previous
  ENABLED tab (wrapping around), Home/End jump to the first/last enabled
  tab. disabled tabs use the native disabled attribute — unfocusable,
  unclickable, and skipped by arrow-key traversal. Clicking a tab or moving
  focus onto one calls onValueChange(tab.value); PageHeader holds no
  internal selection state, it's fully controlled by value/onValueChange.
- PageHeader renders the tablist only — it never renders a tabpanel. Wire
  aria-controls yourself: give each panel a stable id and either point the
  tab's aria-controls at it (fork this component to add that prop) or just
  give the panel aria-labelledby pointing at the tab, whichever matches how
  your app already associates tabs with content.
- sticky adds position/background/blur only (sticky top-0 z-30
  bg-background/95 backdrop-blur) — it never sets overflow-hidden, so
  focus-visible rings on interior controls (like the tab strip) are never
  clipped while the header is pinned.
- The tab strip's scroll container is horizontally scrollable
  (overflow-x-auto) with a small negative-margin/padding pair around it so
  focus rings on the first/last tab aren't cut off by the scroll clip.

Rendering & styling
- Semantic tokens only: text-foreground for the title, text-muted-foreground
  for description/icon/inactive tabs, border-primary for the active tab
  underline, bg-muted for the tab badge pill, bg-background/95 for the
  sticky backdrop. cn() merges consumer className throughout.
- size="sm" vs "md" only changes padding, title text size and icon size —
  never structure.
- No decorative motion in this component (tab underline is a plain
  transition-colors, not a sliding indicator), so there's no
  prefers-reduced-motion branch to write.

Customization levers
- Heading level: titleAs swaps the rendered tag (h1/h2/h3) for pages that
  nest this under another h1, with zero visual change.
- Tab visual style: swap the border-b-2 underline for a pill/segment look —
  only the active/inactive class branches change, the roving-tabindex
  engine is untouched.
- Density: size adds a third value (e.g. "lg") the same way sm/md are
  defined — one more entry in the SIZE map.
- Sticky offset: change top-0 to a fixed offset (top-14) when the header
  sits below another sticky bar (e.g. a global app topbar).
- Meta/actions content: both are plain ReactNode slots — status badges,
  avatar stacks, a dropdown menu, a search box, anything the page needs;
  PageHeader only owns the layout and wrap behavior around them.

Concepts

  • Actions wrap before title clamps — the title/meta block is the flex-1 min-w-0 item with no floor, so it always shrinks first; once the actions block genuinely can't fit beside it, the whole actions group relocates below the title row instead of either side getting squeezed into a sliver.
  • Controlled, panel-less tablistvalue/onValueChange make the tab strip fully controlled (PageHeader keeps no selection state of its own), and it deliberately renders no tabpanel — that DOM belongs to whatever content the tabs are switching.
  • Roving tabindex — only the active (or first enabled, as a fallback) tab is reachable by Tab; arrow keys move both focus and selection among the enabled tabs only, so a disabled tab is invisible to keyboard traversal, not just visually dimmed.
  • Sticky without occlusionsticky only adds positioning, a translucent blurred background and a z-index; it never introduces overflow-hidden, so a focus ring on a tab or action button stays fully visible even while the header is pinned to the top of a scrolling container.
  • Clamp, not collapse — both title (2 lines) and description (2 lines) use line-clamp, keeping the header's height predictable regardless of how long a caller's string is, instead of letting either grow the header unboundedly.

On This Page