Skip to content

Application page header

Breadcrumb, title with record count, an action cluster that collapses to icons, and view tabs.

Headersintermediateheaderapplicationtoolbartabsadmin

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

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.