Skip to content
UI Kit
Accent color

Page header

The head of an inner page. Where you are, what the page is and what you can do here, for list pages, records, profiles and the inner pages of a website. App headers hand over to a compact bar once they scroll away.

One of 2 components in Page headers

6 variants

Variants

With actions

For list pages. Breadcrumbs, the title with a count and the actions on the right. On phones the main action takes the full row.

Open in Studio

Record detail

For a single record. Back link, status, the key facts, copy link with a check and more actions in a menu.

Open in Studio

With tabs

The sections of a record as tabs with counts. The marker glides, the row sticks to the top and casts a shadow once it does.

Open in Studio

With key figures

A title over four figures that count up as they scroll in. The period switch glides and recounts every figure.

Open in Studio

Cover

For profiles and project pages. A picture band that moves slower than the page, an avatar over its edge, a follow toggle and tabs.

Open in Studio

Centered with search

For the inner pages of a website. Kicker, title and lead in the middle, a search with suggestions and a light that drifts while you scroll.

Open in Studio

Details

What Page header brings along, read from the registry and the source.

Category
Page headers
Area
Layout
Variants
6
Version
1.0.1 · since 1.1.0
File
components/ui/page-header.tsx
npm packages
None
Languages
English and German, 8 texts of its own

Props

The PageHeaderProps interface from components/ui/page-header.tsx, with the comments from the code.

  • variantoptional
    PageHeaderVariant

    actionsdetailwith-tabswith-statscovercentered

  • titlerequired
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • eyebrowoptional
    string

    A short line above the title (centered, cover)

  • breadcrumbsoptional
    Crumb[]
  • backoptional
    { label: string; href: string }

    detail: a back link instead of breadcrumbs

  • countoptional
    number

    A number next to the title, e.g. the entries of a list

  • badgeoptional
    { label: string; tone?: PageHeaderBadgeTone }
  • metaoptional
    PageHeaderMeta[]
  • actionsoptional
    PageHeaderAction[]

    The last primary action sits at the far right

  • menuoptional
    MenuItem[]

    More actions behind a ⋯ button

  • copyLinkoptional
    string

    detail: a button that copies this link

  • tabsoptional
    PageHeaderTab[]
  • onTabChangeoptional
    (index: number) => void
  • statsoptional
    PageHeaderStat[]

    with-stats

  • periodsoptional
    string[]
  • defaultPeriodoptional
    number
  • coveroptional
    React.ReactNode

    cover: the picture band and the avatar or logo

  • avataroptional
    React.ReactNode
  • alignoptional
    'center' | 'start'

    centered: start sets kicker, title, lead and content flush left, for inner pages whose content runs left. Default center.

  • searchoptional
    { placeholder: string; action?: string; suggestions?: string[] }

    centered: a search field with suggestions

  • onSearchoptional
    (query: string) => void
  • condenseoptional
    boolean

    App variants: a compact bar with title and main action takes over at the top once the header scrolls away. Default true for actions and detail. with-tabs keeps its tab row at the top instead, with the main action at its end.

  • stickyTabsoptional
    boolean

    with-tabs: the tab row sticks to the top of the page. Default true.

  • childrenoptional
    React.ReactNode

    Content under the header, e.g. filters or a toolbar

Built-in texts

Page header writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 8 texts
moreEnglish More actionsGerman Weitere Aktionen
copyEnglish Copy linkGerman Link kopieren
copiedEnglish Link copiedGerman Link kopiert
tabsEnglish SectionsGerman Bereiche
periodsEnglish PeriodGerman Zeitraum
searchEnglish SearchGerman Suchen
suggestionsEnglish Popular searchesGerman Oft gesucht
vsEnglish vs. previous periodGerman zum Vorzeitraum

Accessibility

Read from the source of the component.

  • Page header handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
  • Sets aria-checked, aria-current, aria-label, aria-live and aria-pressed, so screen readers announce purpose and state.
  • Uses the roles radio, radiogroup and search.
  • Announces changes through a live region without moving focus.
  • Every field has a label, visible or for screen readers.
  • Adds text for screen readers where icons and graphics stand alone.
  • Shows a visible focus ring when used with the keyboard.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still. 9 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 774 lines
components/ui/page-header.tsx
'use client';

import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn, prefersReducedMotion, round, useInView, useLocale, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
import { Breadcrumbs, type Crumb } from '@/components/ui/breadcrumbs';
import { DropdownMenu, type MenuItem } from '@/components/ui/dropdown-menu';
import { NumberTicker } from '@/components/ui/number-ticker';
import { Parallax } from '@/components/ui/motion';

/*
 * The head of an inner page: where you are, what this page is and what you
 * can do here. Six layouts for apps and websites:
 *
 *   actions     list pages: breadcrumbs, title with a count, actions on the right
 *   detail      a single record: back link, status, facts, copy link and a menu
 *   with-tabs   sections of one record as tabs that stick to the top
 *   with-stats  a title over a row of key figures with a period switch
 *   cover       profiles and projects: a picture band, an avatar, actions
 *   centered    inner pages of a website: kicker, title, lead and a search
 *
 * Motion, server-rendered in its final state: the parts rise one after
 * another when the header comes into view, the tab marker glides, figures
 * count up as they scroll in, the cover moves slower than the page, and app
 * headers hand over to a compact bar at the top once they scroll away.
 */

export type PageHeaderVariant = 'actions' | 'detail' | 'with-tabs' | 'with-stats' | 'cover' | 'centered';

export interface PageHeaderAction {
  label: string;
  href?: string;
  icon?: IconName;
  /** primary for the one main action, the rest default to outline */
  variant?: 'primary' | 'secondary' | 'outline' | 'ghost';
  /** Makes the action a toggle, the label it shows while pressed (Follow → Following) */
  pressedLabel?: string;
  /** Start pressed */
  pressed?: boolean;
}

export interface PageHeaderTab {
  label: string;
  href?: string;
  count?: number;
  active?: boolean;
}

export interface PageHeaderStat {
  label: string;
  /** One value per period, or a single value */
  values: number[];
  format?: Intl.NumberFormatOptions;
  /** Change against the period before, one per period, e.g. "+12 %" */
  changes?: string[];
  /** Direction of the change, one per period. up is good, down is bad, flat is neutral. */
  trends?: ('up' | 'down' | 'flat')[];
}

export interface PageHeaderMeta {
  icon?: IconName;
  label: string;
  value?: string;
}

export type PageHeaderBadgeTone = 'neutral' | 'accent' | 'success' | 'warning';

export interface PageHeaderProps {
  variant?: PageHeaderVariant;
  title: React.ReactNode;
  description?: React.ReactNode;
  /** A short line above the title (centered, cover) */
  eyebrow?: string;
  breadcrumbs?: Crumb[];
  /** detail: a back link instead of breadcrumbs */
  back?: { label: string; href: string };
  /** A number next to the title, e.g. the entries of a list */
  count?: number;
  badge?: { label: string; tone?: PageHeaderBadgeTone };
  meta?: PageHeaderMeta[];
  /** The last primary action sits at the far right */
  actions?: PageHeaderAction[];
  /** More actions behind a ⋯ button */
  menu?: MenuItem[];
  /** detail: a button that copies this link */
  copyLink?: string;
  tabs?: PageHeaderTab[];
  onTabChange?: (index: number) => void;
  /** with-stats */
  stats?: PageHeaderStat[];
  periods?: string[];
  defaultPeriod?: number;
  /** cover: the picture band and the avatar or logo */
  cover?: React.ReactNode;
  avatar?: React.ReactNode;
  /** centered: start sets kicker, title, lead and content flush left, for inner pages whose content runs left. Default center. */
  align?: 'center' | 'start';
  /** centered: a search field with suggestions */
  search?: { placeholder: string; action?: string; suggestions?: string[] };
  onSearch?: (query: string) => void;
  /** App variants: a compact bar with title and main action takes over at the top once the header scrolls away. Default true for actions and detail. with-tabs keeps its tab row at the top instead, with the main action at its end. */
  condense?: boolean;
  /** with-tabs: the tab row sticks to the top of the page. Default true. */
  stickyTabs?: boolean;
  /** Content under the header, e.g. filters or a toolbar */
  children?: React.ReactNode;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const TEXTS = {
  en: { more: 'More actions', copy: 'Copy link', copied: 'Link copied', tabs: 'Sections', periods: 'Period', search: 'Search', suggestions: 'Popular searches', vs: 'vs. previous period' },
  de: { more: 'Weitere Aktionen', copy: 'Link kopieren', copied: 'Link kopiert', tabs: 'Bereiche', periods: 'Zeitraum', search: 'Suchen', suggestions: 'Oft gesucht', vs: 'zum Vorzeitraum' },
};
type Texts = (typeof TEXTS)['en'];

const BADGE: Record<PageHeaderBadgeTone, string> = {
  neutral: 'bg-muted text-muted-foreground',
  accent: 'bg-[color-mix(in_oklab,var(--primary)_13%,transparent)] text-[color-mix(in_oklab,var(--primary)_78%,var(--foreground))]',
  success: 'bg-[color-mix(in_oklab,var(--success)_15%,transparent)] text-success-foreground',
  warning: 'bg-[color-mix(in_oklab,var(--warning)_18%,transparent)] text-warning-foreground',
};

const STYLES = `
.kph-in>*{animation:kit-rise .7s var(--ease-kit) both;animation-delay:calc(var(--kph-i,0)*70ms)}
@media (prefers-reduced-motion:reduce){.kph-in>*{animation:none}}
`;

const useIsoLayout = typeof window === 'undefined' ? useEffect : useLayoutEffect;

/* ------------------------------------------------------------------ */
/* Parts                                                               */
/* ------------------------------------------------------------------ */

/** Children rise one after another once the header is in view (only if it was out of view first) */
function Rise({ as: Tag = 'div', className, children }: { as?: 'div' | 'header'; className?: string; children: React.ReactNode }) {
  const [ref, inView] = useInView<HTMLDivElement>();
  const waited = useRef(false);
  if (!inView) waited.current = true;
  return (
    <Tag ref={ref as never} className={cn(!inView && '[&>*]:opacity-0', inView && waited.current && 'kph-in', className)}>
      {children}
    </Tag>
  );
}

function StatusBadge({ badge }: { badge: NonNullable<PageHeaderProps['badge']> }) {
  return (
    <span className={cn('inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium', BADGE[badge.tone ?? 'neutral'])}>
      <span className="relative flex size-1.5" aria-hidden>
        {badge.tone !== 'neutral' && <span className="absolute inset-0 animate-kit-pulse rounded-full bg-current" />}
        <span className="relative size-1.5 rounded-full bg-current" />
      </span>
      {badge.label}
    </span>
  );
}

function ActionButton({ a, size = 'md', iconOnPhone, className }: { a: PageHeaderAction; size?: 'sm' | 'md'; /** On phones only the icon shows, the label stays for screen readers */ iconOnPhone?: boolean; className?: string }) {
  const [pressed, setPressed] = useState(!!a.pressed);
  const toggle = a.pressedLabel !== undefined;
  const variant = toggle && pressed ? 'outline' : (a.variant ?? 'outline');
  const label = toggle && pressed ? a.pressedLabel : a.label;
  const icon = toggle && pressed ? 'check' : a.icon;
  const compact = iconOnPhone && !!icon;
  const cls = cn(compact && 'max-sm:w-11 max-sm:gap-0 max-sm:px-0', className);
  if (a.href && !toggle)
    return (
      <Button href={a.href} variant={variant} size={size} iconLeft={icon} className={cls}>
        <span className={cn(compact && 'max-sm:sr-only')}>{label}</span>
      </Button>
    );
  return (
    <Button variant={variant} size={size} iconLeft={icon} aria-pressed={toggle ? pressed : undefined} onClick={() => toggle && setPressed((p) => !p)} className={cls}>
      <span key={label} className={cn(toggle && 'kit-rise [--kit-delay:0ms]', compact && 'max-sm:sr-only')}>
        {label}
      </span>
    </Button>
  );
}

function MoreMenu({ items, t, className }: { items: MenuItem[]; t: Texts; className?: string }) {
  return (
    <DropdownMenu
      align="end"
      items={items}
      trigger={
        <button
          type="button"
          aria-label={t.more}
          className={cn(
            'group/more grid size-11 shrink-0 place-items-center rounded-lg border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] transition-colors hover:bg-accent aria-expanded:bg-accent',
            className,
          )}
        >
          <Icon name="more-horizontal" size={18} className="transition-transform duration-300 ease-[var(--ease-kit)] group-aria-expanded/more:rotate-90 motion-reduce:transition-none" />
        </button>
      }
    />
  );
}

function CopyLink({ value, t }: { value: string; t: Texts }) {
  const [done, setDone] = useState(false);
  useEffect(() => {
    if (!done) return;
    const id = window.setTimeout(() => setDone(false), 1800);
    return () => window.clearTimeout(id);
  }, [done]);
  return (
    <button
      type="button"
      onClick={() => {
        navigator.clipboard?.writeText(value).catch(() => {});
        setDone(true);
      }}
      aria-label={done ? t.copied : t.copy}
      className="relative grid size-11 shrink-0 place-items-center rounded-lg border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] transition-colors hover:bg-accent"
    >
      <Icon name="link" size={18} className={cn('transition-[opacity,transform] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', done && 'scale-50 opacity-0')} />
      <Icon name="check" size={18} className={cn('absolute text-success-foreground transition-[opacity,transform] duration-300 ease-[var(--ease-kit)] motion-reduce:transition-none', !done && 'scale-50 opacity-0')} />
      <span className="sr-only" aria-live="polite">
        {done ? t.copied : ''}
      </span>
    </button>
  );
}

/** Actions in a row. On phones the main action takes the room, the others shrink to icons where they have one. */
function Actions({ actions = [], menu, copyLink, t, className, fill = true }: { actions?: PageHeaderAction[]; menu?: MenuItem[]; copyLink?: string; t: Texts; className?: string; fill?: boolean }) {
  if (!actions.length && !menu?.length && !copyLink) return null;
  // More than three things in a phone row: the side actions shrink to their icons
  const crowded = actions.length + (menu?.length ? 1 : 0) + (copyLink ? 1 : 0) > 3;
  return (
    <div className={cn('flex w-full items-center gap-2 sm:w-auto sm:shrink-0', className)}>
      {copyLink && <CopyLink value={copyLink} t={t} />}
      {menu?.length ? <MoreMenu items={menu} t={t} /> : null}
      {actions.map((a, i) => {
        const main = (a.variant ?? 'outline') === 'primary' || i === actions.length - 1;
        return <ActionButton key={a.label} a={a} iconOnPhone={!main && crowded} className={cn(fill && main && 'max-sm:flex-1', !main && !a.icon && crowded && 'max-sm:hidden')} />;
      })}
    </div>
  );
}

function Title({ title, count, badge, as: Tag = 'h1', className }: { title: React.ReactNode; count?: number; badge?: PageHeaderProps['badge']; as?: 'h1' | 'h2'; className?: string }) {
  const locale = useLocale();
  return (
    <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
      <Tag className={cn('min-w-0 text-balance text-[clamp(1.625rem,1.3rem+1.1vw,2.125rem)] font-semibold leading-[1.15] tracking-[-0.025em] text-foreground', className)}>{title}</Tag>
      {count !== undefined && (
        <span className="inline-flex h-6 items-center rounded-md border border-border bg-surface px-2 text-[13px] font-medium tabular-nums text-muted-foreground">{count.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}</span>
      )}
      {badge && <StatusBadge badge={badge} />}
    </div>
  );
}

function MetaList({ meta, className }: { meta: PageHeaderMeta[]; className?: string }) {
  return (
    <dl className={cn('grid grid-cols-2 gap-x-4 gap-y-3 text-[13px] sm:flex sm:flex-wrap sm:gap-x-6 sm:gap-y-2.5', className)}>
      {meta.map((m) => (
        <div key={m.label} className="flex min-w-0 items-center gap-2">
          {m.icon && <Icon name={m.icon} size={15} className="shrink-0 text-subtle-foreground" />}
          <dt className={cn('text-muted-foreground', m.value && 'max-sm:sr-only')}>{m.label}</dt>
          {m.value && <dd className="truncate font-medium text-foreground">{m.value}</dd>}
        </div>
      ))}
    </dl>
  );
}

/**
 * A compact bar at the top of the window that takes over once the header has
 * scrolled away. Portaled into the body, slides in with transform only.
 */
function CondensedBar({ watch, title, action }: { watch: React.RefObject<HTMLElement | null>; title: React.ReactNode; action?: PageHeaderAction }) {
  const [show, setShow] = useState(false);
  const [mounted, setMounted] = useState(false);
  useEffect(() => {
    setMounted(true);
    const el = watch.current;
    if (!el || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver(([e]) => setShow(!e.isIntersecting && e.boundingClientRect.top < 0), { threshold: 0 });
    io.observe(el);
    return () => io.disconnect();
  }, [watch]);
  if (!mounted) return null;
  return createPortal(
    <div
      aria-hidden={!show}
      inert={!show}
      className={cn(
        'fixed inset-x-0 top-0 z-40 border-b border-border bg-[color-mix(in_oklab,var(--background)_86%,transparent)] backdrop-blur-md transition-[transform,opacity] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none',
        show ? 'translate-y-0 opacity-100' : '-translate-y-full opacity-0',
      )}
    >
      <div className="kit-container flex h-14 items-center justify-between gap-4">
        <p className="min-w-0 truncate text-[15px] font-semibold tracking-[-0.015em] text-foreground">{title}</p>
        {action && <ActionButton a={action} size="sm" />}
      </div>
    </div>,
    document.body,
  );
}

/* ------------------------------------------------------------------ */
/* Tabs with a gliding marker                                          */
/* ------------------------------------------------------------------ */

function HeaderTabs({ tabs, onTabChange, sticky, bleed, action, t }: { tabs: PageHeaderTab[]; onTabChange?: (i: number) => void; sticky: boolean; /** Full width band with the content width inside (with-tabs), else a row inside the container */ bleed?: boolean; /** Shown at the end of the row while it sticks (tablet and desktop) */ action?: PageHeaderAction; t: Texts }) {
  const locale = useLocale();
  const [active, setActive] = useState(() => Math.max(0, tabs.findIndex((x) => x.active)));
  const row = useRef<HTMLDivElement>(null);
  const [bar, setBar] = useState<{ x: number; w: number; glide: boolean } | null>(null);
  const sentinel = useRef<HTMLDivElement>(null);
  const [stuck, setStuck] = useState(false);
  const [fade, setFade] = useState('');

  const measure = useCallback(() => {
    const r = row.current;
    const el = r?.querySelector<HTMLElement>('[aria-current="page"]');
    if (!r || !el) return;
    setBar((b) => ({ x: round(el.offsetLeft), w: round(el.offsetWidth), glide: b !== null }));
    const max = r.scrollWidth - r.clientWidth;
    setFade(max <= 1 ? '' : r.scrollLeft <= 1 ? 'kit-fade-end' : r.scrollLeft >= max - 1 ? 'kit-fade-start' : 'kit-fade-x');
  }, []);
  useIsoLayout(() => {
    measure();
  }, [active, measure]);
  useEffect(() => {
    const r = row.current;
    window.addEventListener('resize', measure);
    r?.addEventListener('scroll', measure, { passive: true });
    document.fonts?.ready.then(measure).catch(() => {});
    return () => {
      window.removeEventListener('resize', measure);
      r?.removeEventListener('scroll', measure);
    };
  }, [measure]);
  useEffect(() => {
    const el = sentinel.current;
    if (!sticky || !el || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver(([e]) => setStuck(!e.isIntersecting && e.boundingClientRect.top < 0));
    io.observe(el);
    return () => io.disconnect();
  }, [sticky]);

  const pick = (i: number) => {
    setActive(i);
    onTabChange?.(i);
    row.current?.querySelectorAll<HTMLElement>('a')[i]?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
  };

  return (
    <>
      <div ref={sentinel} aria-hidden className="h-px" />
      <div className={cn('z-30 border-b border-border transition-shadow duration-300', sticky && 'sticky top-0 bg-[color-mix(in_oklab,var(--background)_88%,transparent)] backdrop-blur-md', stuck && 'shadow-[var(--shadow-md)]')}>
        <nav aria-label={t.tabs} className={cn('relative', bleed && 'kit-container')}>
          <div ref={row} className={cn('kit-no-scrollbar relative -mx-3 -mb-px flex gap-1 overflow-x-auto [&_a:focus-visible]:outline-offset-[-2px] [&_button:focus-visible]:outline-offset-[-2px]', fade)}>
            {tabs.map((tab, i) => (
              <a
                key={tab.label}
                href={tab.href ?? '#'}
                aria-current={i === active ? 'page' : undefined}
                onClick={(e) => {
                  if (!tab.href || tab.href === '#') e.preventDefault();
                  pick(i);
                }}
                className={cn(
                  'group/tab relative flex h-12 shrink-0 items-center gap-2 whitespace-nowrap rounded-md px-3 text-sm font-medium outline-none transition-colors duration-300 focus-visible:outline-solid focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-[var(--ring)]',
                  i === active ? 'text-foreground' : 'text-muted-foreground hover:text-foreground',
                )}
              >
                <span className="absolute inset-x-0.5 inset-y-2 -z-10 rounded-md bg-accent opacity-0 transition-opacity duration-200 group-hover/tab:opacity-100" aria-hidden />
                {tab.label}
                {tab.count !== undefined && (
                  <span className={cn('rounded-full px-1.5 text-[11px] tabular-nums leading-5 transition-colors duration-300', i === active ? 'bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]' : 'bg-muted text-muted-foreground')}>
                    {tab.count.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US')}
                  </span>
                )}
                {/* Each tab draws its own mark until the shared one is measured */}
                {!bar && i === active && <span aria-hidden className="absolute inset-x-3 bottom-0 h-0.5 rounded-full bg-primary" />}
              </a>
            ))}
            {action && <span aria-hidden className="w-40 shrink-0 max-sm:hidden" />}
            {bar && (
              <span
                aria-hidden
                className={cn('pointer-events-none absolute bottom-0 left-0 h-0.5 w-px origin-left rounded-full bg-primary', bar.glide && 'transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none')}
                style={{ transform: `translateX(${round(bar.x + 12)}px) scaleX(${Math.max(0, round(bar.w - 24))})` }}
              />
            )}
          </div>
          {action && (
            <div
              inert={!stuck}
              className={cn('absolute inset-y-0 right-6 flex items-center transition-[opacity,transform] duration-500 ease-[var(--ease-kit)] max-sm:hidden lg:right-8 motion-reduce:transition-none', stuck ? 'opacity-100' : 'pointer-events-none translate-y-1 opacity-0')}
            >
              <ActionButton a={action} size="sm" />
            </div>
          )}
        </nav>
      </div>
    </>
  );
}

/* ------------------------------------------------------------------ */
/* Stats with a period switch                                          */
/* ------------------------------------------------------------------ */

function PeriodSwitch({ periods, value, onChange, t }: { periods: string[]; value: number; onChange: (i: number) => void; t: Texts }) {
  const refs = useRef<(HTMLButtonElement | null)[]>([]);
  const move = (to: number) => {
    const n = (to + periods.length) % periods.length;
    onChange(n);
    refs.current[n]?.focus();
  };
  return (
    <div role="radiogroup" aria-label={t.periods} className="relative grid w-full rounded-lg border border-border bg-surface p-0.5 shadow-[var(--highlight)] sm:w-auto" style={{ gridTemplateColumns: `repeat(${periods.length}, minmax(0, 1fr))` }}>
      <span
        aria-hidden
        className="absolute bottom-0.5 left-0.5 top-0.5 rounded-md bg-background shadow-[var(--highlight),var(--shadow-sm)] ring-1 ring-border transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none"
        style={{ width: `calc((100% - 4px) / ${periods.length})`, transform: `translateX(${value * 100}%)` }}
      />
      {periods.map((p, i) => (
        <button
          key={p}
          ref={(el) => {
            refs.current[i] = el;
          }}
          type="button"
          role="radio"
          aria-checked={i === value}
          tabIndex={i === value ? 0 : -1}
          onClick={() => onChange(i)}
          onKeyDown={(e) => {
            if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
              e.preventDefault();
              move(value + 1);
            } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
              e.preventDefault();
              move(value - 1);
            }
          }}
          className={cn('relative z-10 flex min-h-11 items-center justify-center whitespace-nowrap rounded-md px-3.5 text-[13px] font-medium outline-none transition-colors duration-300 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-[var(--ring)] sm:min-h-9', i === value ? 'text-foreground' : 'text-muted-foreground hover:text-foreground')}
        >
          {p}
        </button>
      ))}
    </div>
  );
}

const TREND: Record<'up' | 'down' | 'flat', { icon: IconName; cls: string }> = {
  up: { icon: 'trending-up', cls: 'text-success-foreground' },
  down: { icon: 'trending-down', cls: 'text-destructive' },
  flat: { icon: 'arrow-right', cls: 'text-muted-foreground' },
};

function StatsRow({ stats, period, t }: { stats: PageHeaderStat[]; period: number; t: Texts }) {
  const cols = stats.length >= 4 ? 'grid-cols-2 lg:grid-cols-4' : stats.length === 3 ? 'grid-cols-1 sm:grid-cols-3' : 'grid-cols-2';
  return (
    <dl className={cn('grid gap-px overflow-hidden rounded-xl border border-border bg-border shadow-[var(--highlight),var(--shadow-sm)]', cols)}>
      {stats.map((s, i) => {
        const v = s.values[Math.min(period, s.values.length - 1)];
        const change = s.changes?.[Math.min(period, s.changes.length - 1)];
        const trend = s.trends?.[Math.min(period, s.trends.length - 1)] ?? 'flat';
        return (
          <div key={s.label} className="group/stat relative flex min-w-0 flex-col bg-card px-4 py-4 sm:px-5 sm:py-5">
            <dt className="truncate text-[13px] text-muted-foreground">{s.label}</dt>
            <dd className="mt-2 text-[clamp(1.375rem,1.15rem+0.8vw,1.75rem)] font-semibold leading-none tracking-[-0.025em] text-foreground tabular-nums">
              <NumberTicker key={period} value={v} format={s.format} duration={period === 0 ? 1400 : 700} delay={period === 0 ? i * 90 : 0} />
            </dd>
            {change && (
              <dd className={cn('mt-2.5 flex items-center gap-1 text-[12px] font-medium tabular-nums', TREND[trend].cls)}>
                <Icon name={TREND[trend].icon} size={14} />
                {change}
                <span className="font-normal text-subtle-foreground max-sm:sr-only">{t.vs}</span>
              </dd>
            )}
          </div>
        );
      })}
    </dl>
  );
}

/* ------------------------------------------------------------------ */
/* Search for the centered header                                      */
/* ------------------------------------------------------------------ */

function HeaderSearch({ search, onSearch, t }: { search: NonNullable<PageHeaderProps['search']>; onSearch?: (q: string) => void; t: Texts }) {
  const [q, setQ] = useState('');
  const input = useRef<HTMLInputElement>(null);
  return (
    <div className="mx-auto mt-9 w-full max-w-[34rem]">
      <form
        role="search"
        onSubmit={(e) => {
          e.preventDefault();
          onSearch?.(q);
        }}
        className="group/search relative flex h-14 items-center gap-2 rounded-xl border border-border-strong bg-card pl-4 pr-1.5 shadow-[var(--highlight),var(--shadow-md)] transition-[box-shadow,border-color] duration-300 focus-within:border-[color-mix(in_oklab,var(--primary)_55%,var(--border-strong))] focus-within:shadow-[var(--highlight),0_0_0_4px_color-mix(in_oklab,var(--primary)_14%,transparent),var(--shadow-md)]"
      >
        <Icon name="search" size={18} className="shrink-0 text-subtle-foreground transition-colors duration-300 group-focus-within/search:text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />
        <label className="sr-only" htmlFor="kph-search">
          {t.search}
        </label>
        <input
          ref={input}
          id="kph-search"
          type="search"
          value={q}
          onChange={(e) => setQ(e.target.value)}
          placeholder={search.placeholder}
          className="h-full w-0 min-w-0 flex-1 bg-transparent text-[15px] text-foreground outline-none placeholder:text-subtle-foreground [&::-webkit-search-cancel-button]:hidden"
        />
        <Button type="submit" size="sm" className="max-sm:h-11 max-sm:px-3.5">
          {search.action ?? t.search}
        </Button>
      </form>
      {search.suggestions?.length ? (
        <div className="mt-4 flex flex-wrap items-center justify-center gap-2">
          <span className="text-[13px] text-subtle-foreground max-sm:w-full max-sm:text-center">{t.suggestions}</span>
          {search.suggestions.map((s) => (
            <button
              key={s}
              type="button"
              onClick={() => {
                setQ(s);
                input.current?.focus();
              }}
              className="inline-flex h-11 items-center rounded-full border border-border bg-surface px-3.5 text-[13px] text-muted-foreground transition-[color,border-color,transform] duration-200 hover:-translate-y-px hover:border-border-strong hover:text-foreground sm:h-8"
            >
              {s}
            </button>
          ))}
        </div>
      ) : null}
    </div>
  );
}

/* ------------------------------------------------------------------ */
/* PageHeader                                                          */
/* ------------------------------------------------------------------ */

/**
 * The head of an inner page.
 *   <PageHeader variant="actions" breadcrumbs={[…]} title="Invoices" count={128} actions={[{ label: 'Export' }, { label: 'New invoice', variant: 'primary', icon: 'plus' }]} />
 */
export function PageHeader(props: PageHeaderProps) {
  const { variant = 'actions', align = 'center', title, description, eyebrow, breadcrumbs, back, count, badge, meta, actions, menu, copyLink, tabs, onTabChange, stats, periods, defaultPeriod = 0, cover, avatar, search, onSearch, stickyTabs = true, children, labels, className } = props;
  const t = useTexts(TEXTS, labels);
  const head = useRef<HTMLDivElement>(null);
  const [period, setPeriod] = useState(defaultPeriod);
  // with-tabs hands over to its sticky tab row instead, which then carries the main action
  const condense = props.condense ?? (variant === 'actions' || variant === 'detail');
  const main = actions?.length ? [...actions].reverse().find((a) => a.variant === 'primary') ?? actions[actions.length - 1] : undefined;
  const plainTitle = typeof title === 'string' ? title : null;

  const styles = (
    <style href="kit-page-header" precedence="default">
      {STYLES}
    </style>
  );
  const crumbs = breadcrumbs?.length ? <Breadcrumbs items={breadcrumbs} /> : null;

  if (variant === 'centered') {
    const start = align === 'start';
    return (
      <header className={cn('relative isolate overflow-hidden border-b border-border', className)}>
        {styles}
        <Parallax speed={-60} className="pointer-events-none absolute inset-0 -z-10">
          <div aria-hidden className="absolute inset-x-0 -top-24 h-[130%] bg-[radial-gradient(55%_60%_at_50%_0%,var(--glow),transparent_72%)]" />
          <div aria-hidden className="absolute inset-0 bg-[linear-gradient(to_right,color-mix(in_oklab,var(--foreground)_6%,transparent)_1px,transparent_1px),linear-gradient(to_bottom,color-mix(in_oklab,var(--foreground)_6%,transparent)_1px,transparent_1px)] [background-size:48px_48px] [mask-image:radial-gradient(ellipse_60%_70%_at_50%_10%,#000_20%,transparent_75%)]" />
        </Parallax>
        <Rise className={cn('kit-container flex flex-col py-16 sm:py-20 lg:py-24', start ? 'items-start text-left' : 'items-center text-center')}>
          {crumbs && <div style={{ '--kph-i': 0 } as React.CSSProperties} className={cn('mb-8 flex', !start && 'justify-center [&_ol]:justify-center')}>{crumbs}</div>}
          {eyebrow && (
            <p style={{ '--kph-i': 1 } as React.CSSProperties} className="mb-4 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]">
              {eyebrow}
            </p>
          )}
          <h1 style={{ '--kph-i': 2 } as React.CSSProperties} className="max-w-[44rem] text-balance text-h1 font-semibold text-foreground">
            {title}
          </h1>
          {description && (
            <p style={{ '--kph-i': 3 } as React.CSSProperties} className={cn('mt-5 max-w-[36rem] text-lead text-muted-foreground', start ? 'max-w-[40rem] text-pretty' : 'text-balance')}>
              {description}
            </p>
          )}
          {search && (
            <div style={{ '--kph-i': 4 } as React.CSSProperties} className="w-full">
              <HeaderSearch search={search} onSearch={onSearch} t={t} />
            </div>
          )}
          {!search && actions?.length ? (
            <div style={{ '--kph-i': 4 } as React.CSSProperties} className={cn('mt-8 flex flex-wrap gap-3', !start && 'justify-center')}>
              {actions.map((a) => (
                <ActionButton key={a.label} a={a} />
              ))}
            </div>
          ) : null}
          {children && <div className={cn('w-full', start ? 'mt-0' : 'mt-10')}>{children}</div>}
        </Rise>
      </header>
    );
  }

  if (variant === 'cover') {
    return (
      <header className={cn('relative', className)}>
        {styles}
        <div className="kit-container">
          <div className="relative mt-4 h-36 overflow-hidden rounded-2xl border border-border bg-muted sm:mt-6 sm:h-48 lg:h-60">
            {cover && (
              <Parallax speed={-56} className="absolute -inset-y-8 inset-x-0 [&>*]:h-full [&>*]:w-full [&>*]:object-cover">
                {cover}
              </Parallax>
            )}
            <div aria-hidden className="absolute inset-0 bg-[linear-gradient(to_top,color-mix(in_oklab,var(--background)_35%,transparent),transparent_55%)]" />
          </div>
          <Rise className="relative px-1 sm:px-6 lg:px-8">
            <div style={{ '--kph-i': 0 } as React.CSSProperties} className="-mt-11 flex items-end justify-between gap-4 sm:-mt-14">
              <div className="size-22 shrink-0 overflow-hidden rounded-2xl border-4 border-background bg-card shadow-[var(--shadow-md)] transition-transform duration-500 ease-[var(--ease-kit)] hover:-translate-y-1 hover:rotate-[-2deg] motion-reduce:transition-none sm:size-28 [&>*]:size-full">
                {avatar}
              </div>
              <Actions actions={actions} menu={menu} t={t} fill={false} className="w-auto max-sm:hidden" />
            </div>
            <div style={{ '--kph-i': 1 } as React.CSSProperties} className="mt-5">
              {eyebrow && <p className="mb-1.5 text-[13px] text-muted-foreground">{eyebrow}</p>}
              <Title title={title} badge={badge} />
              {description && <p className="mt-3 max-w-[40rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
            </div>
            {meta?.length ? <MetaList meta={meta} className="mt-5" /> : null}
            <Actions actions={actions} menu={menu} t={t} className="mt-6 sm:hidden" />
            {tabs?.length ? (
              <div className="mt-7">
                <HeaderTabs tabs={tabs} onTabChange={onTabChange} sticky={false} t={t} />
              </div>
            ) : (
              <div className="mt-8 border-b border-border" />
            )}
          </Rise>
          {children && <div className="px-1 pt-6 sm:px-6 lg:px-8">{children}</div>}
        </div>
      </header>
    );
  }

  const barTitle = plainTitle ?? title;
  const condensed = condense ? <CondensedBar watch={head} title={barTitle} action={main} /> : null;

  if (variant === 'with-stats') {
    return (
      <header className={cn('kit-container py-8 sm:py-10', className)}>
        {styles}
        <Rise>
          {crumbs && <div style={{ '--kph-i': 0 } as React.CSSProperties} className="mb-5">{crumbs}</div>}
          <div style={{ '--kph-i': 1 } as React.CSSProperties} className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
            <div className="min-w-0">
              <Title title={title} badge={badge} count={count} />
              {description && <p className="mt-2 max-w-[38rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
            </div>
            <div className="flex flex-col-reverse gap-3 sm:flex-row sm:items-center">
              {periods?.length ? <PeriodSwitch periods={periods} value={period} onChange={setPeriod} t={t} /> : null}
              <Actions actions={actions} menu={menu} t={t} />
            </div>
          </div>
          {stats?.length ? (
            <div style={{ '--kph-i': 2 } as React.CSSProperties} className="mt-7">
              <StatsRow stats={stats} period={period} t={t} />
            </div>
          ) : null}
        </Rise>
        {children && <div className="mt-6">{children}</div>}
      </header>
    );
  }

  if (variant === 'with-tabs') {
    return (
      <>
        {styles}
        {condensed}
        <header className={cn('kit-container', className)}>
          <Rise className="pt-8 sm:pt-10">
            {crumbs && <div style={{ '--kph-i': 0 } as React.CSSProperties} className="mb-5">{crumbs}</div>}
            <div ref={head} style={{ '--kph-i': 1 } as React.CSSProperties} className="flex flex-col gap-5 pb-6 sm:flex-row sm:items-end sm:justify-between">
              <div className="flex min-w-0 items-start gap-4">
                {avatar && <div className="size-12 shrink-0 overflow-hidden rounded-xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)] sm:size-14 [&>*]:size-full">{avatar}</div>}
                <div className="min-w-0">
                  <Title title={title} badge={badge} count={count} />
                  {description && <p className="mt-1.5 max-w-[38rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
                </div>
              </div>
              <Actions actions={actions} menu={menu} copyLink={copyLink} t={t} />
            </div>
          </Rise>
        </header>
        {tabs?.length ? <HeaderTabs tabs={tabs} onTabChange={onTabChange} sticky={stickyTabs} bleed action={stickyTabs ? main : undefined} t={t} /> : null}
        {children && <div className="kit-container pt-6">{children}</div>}
      </>
    );
  }

  if (variant === 'detail') {
    return (
      <>
        {styles}
        {condensed}
        <header className={cn('kit-container', className)}>
          <Rise className="border-b border-border py-8 sm:py-10">
            {back ? (
              <a
                href={back.href}
                style={{ '--kph-i': 0 } as React.CSSProperties}
                className="group/back -ml-2 mb-5 inline-flex h-11 items-center gap-1.5 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:text-foreground sm:h-8"
              >
                <Icon name="arrow-left" size={16} className="transition-transform duration-300 ease-[var(--ease-kit)] group-hover/back:-translate-x-0.5 motion-reduce:transition-none" />
                {back.label}
              </a>
            ) : (
              crumbs && <div className="mb-5">{crumbs}</div>
            )}
            <div ref={head} style={{ '--kph-i': 1 } as React.CSSProperties} className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
              <div className="min-w-0">
                <Title title={title} badge={badge} />
                {description && <p className="mt-2 max-w-[42rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
              </div>
              <Actions actions={actions} menu={menu} copyLink={copyLink} t={t} className="lg:pt-0.5" />
            </div>
            {meta?.length ? (
              <div style={{ '--kph-i': 2 } as React.CSSProperties}>
                <MetaList meta={meta} className="mt-6 border-t border-border pt-5 sm:gap-x-8" />
              </div>
            ) : null}
          </Rise>
        </header>
        {children && <div className="kit-container pt-6">{children}</div>}
      </>
    );
  }

  /* actions */
  return (
    <>
      {styles}
      {condensed}
      <header className={cn('kit-container py-8 sm:py-10', className)}>
        <Rise>
          {crumbs && <div style={{ '--kph-i': 0 } as React.CSSProperties} className="mb-5">{crumbs}</div>}
          <div ref={head} style={{ '--kph-i': 1 } as React.CSSProperties} className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
            <div className="min-w-0">
              <Title title={title} count={count} badge={badge} />
              {description && <p className="mt-2 max-w-[38rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
            </div>
            <Actions actions={actions} menu={menu} t={t} />
          </div>
          {children && (
            <div style={{ '--kph-i': 2 } as React.CSSProperties} className="mt-7">
              {children}
            </div>
          )}
        </Rise>
      </header>
    </>
  );
}

Frequent questions

How do I install Page header?

With aiw add @ai-website/page-header. The command puts components/ui/page-header.tsx into your project, the code is yours from then on. It brings along button, icon, breadcrumbs, dropdown-menu, number-ticker and motion. It needs no further npm packages.

Which variants does Page header have?

6 variants, With actions, Record detail, With tabs, With key figures, Cover and Centered with search. You pick one with the variant prop, for example variant="detail".

Can I translate the texts of Page header?

Yes. Page header carries 8 texts of its own in English and German, such as “More actions”, “Copy link” and “Link copied”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Page header?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Page header with aiw_components and install it with aiw_add @ai-website/page-header. Name the variant in your prompt, for example variant="actions", and the assistant writes your content straight into the props.

Can I adjust Page header without code?

Yes, in the Studio. All 6 variants are editable there, texts, lists and switches. The Studio writes the matching PageHeader code to copy.

Often searched

Jump to

Copied to the clipboard