Zum Inhalt springen
UI Kit
Accent

Page Header

Der Kopf einer Unterseite. Wo du bist, was die Seite ist und was du hier tun kannst, für Listen, Datensätze, Profile und die Unterseiten einer Website. App-Köpfe übergeben an eine kompakte Leiste, sobald sie aus dem Bild scrollen.

Eine von 2 Components in Page Header

6 Variants

Variants

Mit Aktionen

Für Listen. Brotkrumen, der Titel mit Anzahl und die Aktionen rechts. Auf dem Telefon nimmt die Hauptaktion die ganze Zeile.

Im Studio öffnen

Datensatz

Für einen einzelnen Datensatz. Zurück-Link, Status, die wichtigsten Fakten, Link kopieren mit Haken und weitere Aktionen im Menü.

Im Studio öffnen

Mit Tabs

Die Bereiche eines Datensatzes als Tabs mit Zahlen. Die Markierung gleitet, die Leiste bleibt oben stehen und wirft dann einen Schatten.

Im Studio öffnen

Mit Kennzahlen

Ein Titel über vier Kennzahlen, die beim Hereinscrollen hochzählen. Der Zeitraum-Umschalter gleitet und zählt jede Zahl neu.

Im Studio öffnen

Titelbild

Für Profile und Projektseiten. Ein Bildband, das langsamer als die Seite zieht, ein Avatar über seiner Kante, Folgen zum Umschalten und Tabs.

Im Studio öffnen

Mittig mit Suche

Für die Unterseiten einer Website. Dachzeile, Titel und Einleitung mittig, eine Suche mit Vorschlägen und ein Licht, das beim Scrollen zieht.

Im Studio öffnen

Details

Was Page Header mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Page Header
Bereich
Layout
Variants
6
Version
1.0.1 · seit 1.1.0
Datei
components/ui/page-header.tsx
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 8 eigene Texte

Props

Die Schnittstelle PageHeaderProps aus components/ui/page-header.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    PageHeaderVariant

    actionsdetailwith-tabswith-statscovercentered

  • titlePflicht
    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

Eingebaute Texte

Diese Texte schreibt Page Header selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 8 Texte anzeigen
moreEnglisch More actionsDeutsch Weitere Aktionen
copyEnglisch Copy linkDeutsch Link kopieren
copiedEnglisch Link copiedDeutsch Link kopiert
tabsEnglisch SectionsDeutsch Bereiche
periodsEnglisch PeriodDeutsch Zeitraum
searchEnglisch SearchDeutsch Suchen
suggestionsEnglisch Popular searchesDeutsch Oft gesucht
vsEnglisch vs. previous periodDeutsch zum Vorzeitraum

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Page Header reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
  • Setzt aria-checked, aria-current, aria-label, aria-live und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen radio, radiogroup und search.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
  • Bewegt sich mit dem Scrollen, der Fortschritt folgt der Position auf der Seite.
  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 9 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 774 Zeilen
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>
    </>
  );
}

Häufige Fragen

Wie installiere ich Page Header?

Mit aiw add @ai-website/page-header. Der Befehl legt die Datei components/ui/page-header.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button, icon, breadcrumbs, dropdown-menu, number-ticker und motion. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Page Header?

6 Varianten, Mit Aktionen, Datensatz, Mit Tabs, Mit Kennzahlen, Titelbild und Mittig mit Suche. Du wählst sie mit der Prop variant, zum Beispiel variant="detail".

Kann ich die Texte von Page Header übersetzen?

Ja. Page Header bringt 8 eigene Texte auf Deutsch und Englisch mit, etwa „Weitere Aktionen“, „Link kopieren“ und „Link kopiert“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Page Header einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Page Header mit aiw_components und installieren sie mit aiw_add @ai-website/page-header. Nenn im Prompt die Variante, etwa variant="actions", dann schreibt der Assistent deine Inhalte direkt in die Props.

Kann ich Page Header ohne Code anpassen?

Ja, im Studio. Alle 6 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden PageHeader-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert