Application page header
Breadcrumb, title with record count, an action cluster that collapses to icons, and view tabs.
Live preview
Source
This exact file renders the preview above.
import { Download, Filter, Plus } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Breadcrumb } from '@/components/ui/breadcrumb'
import { Button } from '@/components/ui/button'
import { Tabs } from '@/components/ui/tabs'
/**
* Application page header
*
* The in-product header: breadcrumb, title with record count, an action
* cluster, and view tabs. The action cluster collapses to icons below `sm`
* rather than wrapping to a second row, which keeps the header height stable
* across breakpoints.
*/
export default function AppPageHeader() {
return (
<div className="border-b border-line bg-surface">
<div className="px-4 pt-4 sm:px-6">
<Breadcrumb
items={[{ label: 'Console', href: '/starters/admin/preview' }, { label: 'Orders' }]}
/>
<div className="mt-3 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2.5">
<h1 className="text-xl font-semibold text-ink-strong">Orders</h1>
<Badge>1,284 records</Badge>
</div>
<p className="mt-1 text-sm text-ink-muted">
Every order placed in the last 90 days, across all regions.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
className="hidden sm:inline-flex"
leadingIcon={<Filter className="size-3.5" />}
>
Filter
</Button>
<Button variant="outline" size="icon-sm" className="sm:hidden" aria-label="Filter">
<Filter className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="hidden sm:inline-flex"
leadingIcon={<Download className="size-3.5" />}
>
Export
</Button>
<Button variant="outline" size="icon-sm" className="sm:hidden" aria-label="Export">
<Download className="size-3.5" />
</Button>
<Button size="sm" leadingIcon={<Plus className="size-3.5" />}>
New order
</Button>
</div>
</div>
</div>
<div className="px-4 sm:px-6">
<Tabs
label="Order views"
appearance="underline"
items={[
{
id: 'all',
label: 'All',
content: <p className="pb-4 text-sm text-ink-muted">1,284 orders.</p>,
},
{
id: 'open',
label: 'Open',
badge: <Badge size="sm">42</Badge>,
content: (
<p className="pb-4 text-sm text-ink-muted">42 orders awaiting fulfilment.</p>
),
},
{
id: 'flagged',
label: 'Flagged',
badge: (
<Badge size="sm" tone="danger">
3
</Badge>
),
content: <p className="pb-4 text-sm text-ink-muted">3 orders need manual review.</p>,
},
{
id: 'archived',
label: 'Archived',
content: <p className="pb-4 text-sm text-ink-muted">Older than 90 days.</p>,
},
]}
/>
</div>
</div>
)
}
components/blocks/headers/app-page.tsx
import { Download, Filter, Plus } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Breadcrumb } from '@/components/ui/breadcrumb'
import { Button } from '@/components/ui/button'
import { Tabs } from '@/components/ui/tabs'
/**
* Application page header
*
* The in-product header: breadcrumb, title with record count, an action
* cluster, and view tabs. The action cluster collapses to icons below `sm`
* rather than wrapping to a second row, which keeps the header height stable
* across breakpoints.
*/
export default function AppPageHeader() {
return (
<div className="border-b border-line bg-surface">
<div className="px-4 pt-4 sm:px-6">
<Breadcrumb
items={[{ label: 'Console', href: '/starters/admin/preview' }, { label: 'Orders' }]}
/>
<div className="mt-3 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2.5">
<h1 className="text-xl font-semibold text-ink-strong">Orders</h1>
<Badge>1,284 records</Badge>
</div>
<p className="mt-1 text-sm text-ink-muted">
Every order placed in the last 90 days, across all regions.
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
className="hidden sm:inline-flex"
leadingIcon={<Filter className="size-3.5" />}
>
Filter
</Button>
<Button variant="outline" size="icon-sm" className="sm:hidden" aria-label="Filter">
<Filter className="size-3.5" />
</Button>
<Button
variant="outline"
size="sm"
className="hidden sm:inline-flex"
leadingIcon={<Download className="size-3.5" />}
>
Export
</Button>
<Button variant="outline" size="icon-sm" className="sm:hidden" aria-label="Export">
<Download className="size-3.5" />
</Button>
<Button size="sm" leadingIcon={<Plus className="size-3.5" />}>
New order
</Button>
</div>
</div>
</div>
<div className="px-4 sm:px-6">
<Tabs
label="Order views"
appearance="underline"
items={[
{
id: 'all',
label: 'All',
content: <p className="pb-4 text-sm text-ink-muted">1,284 orders.</p>,
},
{
id: 'open',
label: 'Open',
badge: <Badge size="sm">42</Badge>,
content: (
<p className="pb-4 text-sm text-ink-muted">42 orders awaiting fulfilment.</p>
),
},
{
id: 'flagged',
label: 'Flagged',
badge: (
<Badge size="sm" tone="danger">
3
</Badge>
),
content: <p className="pb-4 text-sm text-ink-muted">3 orders need manual review.</p>,
},
{
id: 'archived',
label: 'Archived',
content: <p className="pb-4 text-sm text-ink-muted">Older than 90 days.</p>,
},
]}
/>
</div>
</div>
)
}
components/ui/tabs.tsx
'use client'
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import { cn } from '@/lib/cn'
/**
* Tabs
*
* Implements the ARIA tabs pattern with *manual* activation: arrow keys move
* focus, Enter or Space activates. Automatic activation is the wrong default
* for a documentation site, where a panel may be expensive and a keyboard user
* arrowing past three tabs should not mount all three.
*
* Home/End jump to the ends, and the tab list scrolls rather than wrapping on
* narrow screens so the active tab is always reachable.
*/
export interface TabItem {
id: string
label: ReactNode
content: ReactNode
disabled?: boolean
/** Optional count or status rendered after the label. */
badge?: ReactNode
}
export interface TabsProps {
items: TabItem[]
defaultTab?: string
/** Controlled selection. */
value?: string
onValueChange?: (id: string) => void
appearance?: 'underline' | 'enclosed' | 'pill'
className?: string
label?: string
}
export function Tabs({
items,
defaultTab,
value: controlledValue,
onValueChange,
appearance = 'underline',
className,
label = 'Tabs',
}: TabsProps) {
const uid = useId()
const [uncontrolled, setUncontrolled] = useState(defaultTab ?? items[0]?.id ?? '')
const active = controlledValue ?? uncontrolled
const refs = useRef<Record<string, HTMLButtonElement | null>>({})
const select = (id: string) => {
if (controlledValue === undefined) setUncontrolled(id)
onValueChange?.(id)
}
const enabled = items.filter((item) => !item.disabled)
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {
const index = enabled.findIndex((item) => item.id === id)
if (index === -1) return
let nextIndex: number | null = null
if (event.key === 'ArrowRight') nextIndex = (index + 1) % enabled.length
else if (event.key === 'ArrowLeft') nextIndex = (index - 1 + enabled.length) % enabled.length
else if (event.key === 'Home') nextIndex = 0
else if (event.key === 'End') nextIndex = enabled.length - 1
else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
select(id)
return
}
if (nextIndex === null) return
event.preventDefault()
const nextId = enabled[nextIndex]?.id
if (nextId) refs.current[nextId]?.focus()
}
const activeItem = items.find((item) => item.id === active) ?? items[0]
const listClasses = {
underline: 'flex gap-1 border-b border-line',
enclosed: 'flex gap-1 rounded-md border border-line bg-surface-sunken p-1',
pill: 'flex flex-wrap gap-1.5',
}[appearance]
return (
<div className={cn('flex flex-col', className)}>
<div
className={cn(
'hide-scrollbar overflow-x-auto',
appearance === 'underline' && 'border-b border-line',
)}
>
<div
role="tablist"
aria-label={label}
className={cn(listClasses, appearance === 'underline' && 'border-b-0')}
>
{items.map((item) => {
const selected = item.id === active
return (
<button
key={item.id}
ref={(el) => {
refs.current[item.id] = el
}}
type="button"
role="tab"
id={`${uid}-tab-${item.id}`}
aria-selected={selected}
aria-controls={`${uid}-panel-${item.id}`}
tabIndex={selected ? 0 : -1}
disabled={item.disabled}
onClick={() => select(item.id)}
onKeyDown={(event) => onKeyDown(event, item.id)}
className={cn(
'inline-flex shrink-0 items-center gap-2 text-sm font-medium whitespace-nowrap transition-colors duration-150',
'disabled:cursor-not-allowed disabled:opacity-50',
appearance === 'underline' &&
cn(
'-mb-px border-b-2 px-3 py-2',
selected
? 'border-accent text-ink-strong'
: 'border-transparent text-ink-muted hover:text-ink',
),
appearance === 'enclosed' &&
cn(
'rounded-sm px-3 py-1.5',
selected
? 'bg-surface text-ink-strong shadow-xs'
: 'text-ink-muted hover:text-ink',
),
appearance === 'pill' &&
cn(
'rounded-full border px-3 py-1',
selected
? 'border-accent bg-accent-soft text-accent-soft-ink'
: 'border-line text-ink-muted hover:border-line-strong hover:text-ink',
),
)}
>
{item.label}
{item.badge}
</button>
)
})}
</div>
</div>
{activeItem ? (
<div
role="tabpanel"
id={`${uid}-panel-${activeItem.id}`}
aria-labelledby={`${uid}-tab-${activeItem.id}`}
tabIndex={0}
className="pt-4 focus-visible:outline-2 focus-visible:outline-offset-2"
>
{activeItem.content}
</div>
) : null}
</div>
)
}
components/ui/breadcrumb.tsx
import Link from 'next/link'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/cn'
/**
* Breadcrumb
*
* A `<nav>` wrapping an ordered list. The final crumb is not a link and
* carries `aria-current="page"`, and the separators are decorative text inside
* `aria-hidden` spans so a screen reader reads "Components, Button" rather
* than "Components chevron Button".
*
* On narrow screens the trail scrolls horizontally instead of wrapping into an
* unreadable stack.
*/
export interface Crumb {
label: string
href?: string
}
export interface BreadcrumbProps {
items: Crumb[]
className?: string
/** Collapses the middle of very deep trails behind an ellipsis. */
maxItems?: number
}
export function Breadcrumb({ items, className, maxItems }: BreadcrumbProps) {
const shouldCollapse = maxItems !== undefined && items.length > maxItems
const visible: Array<Crumb | 'ellipsis'> = shouldCollapse
? [items[0] as Crumb, 'ellipsis', ...items.slice(items.length - (maxItems - 1))]
: items
return (
<nav aria-label="Breadcrumb" className={cn('min-w-0', className)}>
<ol className="hide-scrollbar flex items-center gap-1 overflow-x-auto text-xs whitespace-nowrap">
{visible.map((item, index) => {
const isLast = index === visible.length - 1
return (
<li
key={typeof item === 'string' ? `ellipsis-${index}` : `${item.label}-${index}`}
className="flex items-center gap-1"
>
{index > 0 ? (
<ChevronRight className="size-3 shrink-0 text-ink-subtle" aria-hidden="true" />
) : null}
{item === 'ellipsis' ? (
<span className="px-1 text-ink-subtle" aria-hidden="true">
…
</span>
) : isLast || !item.href ? (
<span
className={cn('font-medium', isLast ? 'text-ink' : 'text-ink-muted')}
aria-current={isLast ? 'page' : undefined}
>
{item.label}
</span>
) : (
<Link
href={item.href}
className="rounded-xs text-ink-muted transition-colors duration-150 hover:text-ink"
>
{item.label}
</Link>
)}
</li>
)
})}
</ol>
</nav>
)
}
Demo source — adapt to your project. Foundry is not published as a package.
Usage
The in-product header. The action cluster collapses to icons below `sm` rather than wrapping to a second row, which keeps the header height stable across breakpoints — important when content below is virtualised.
- Collapsed icon buttons keep their labels via `aria-label`; the visible-text version is hidden, not replaced.
- Tab counts belong in badges, and badges belong in the accessible name too.
Variants and states
Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.
- Record count badge
- Icon-collapsing actions
- View tabs with counts
Accessibility
- Label parity
- The `sm`-and-up text button and the mobile icon button carry the same accessible name.
- Tabs
- Manual activation via the Tabs primitive; the panel is focusable.
Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.
Related
All navigationAdmin starter
An internal console: overview, a filterable member table, orders, analytics and settings — all at compact density on the same components as the marketing starters.
advancedFeatured4 variantsTabs
ARIA tabs with manual activation and three appearances.
intermediate5 variantsDashboard shell
Top bar plus persistent sidebar, with the same destinations rendered into a drawer below lg.
intermediateFeatured4 variants