Section Header
Köpfe für Sections, die mehr tragen als einen Titel. Ein Link auf der Grundlinie, Filter für das Raster, eine Kapitelmarke mit Linie, eine große Kennzahl, eine Seitenspalte, die beim Scrollen stehen bleibt, und ein Satz, der beim Lesen aufleuchtet.
Eine von 2 Components in Page Header
Variants
Details
Was Section Header mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Page Header
- Bereich
- Layout
- Variants
- 6
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/section-header.tsx
- Bringt mit
- utilskiticonnumber-tickerword-revealmotion
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 2 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SectionHeadingProps aus components/ui/section-header.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalSectionHeadingVariantwith-linkwith-filterschapterwith-statsticky-sidestatement
eyebrowoptionalstringtitlePflichtReact.ReactNodeFor statement a plain string, its words light up one after another
descriptionoptionalReact.ReactNodelinkoptional{ label: string; href: string }with-link (and optional on the others): a link next to the title
filtersoptionalstring[]with-filters
defaultFilteroptionalnumberonFilterChangeoptional(index: number) => voidmarkoptionalstringchapter: the mark, e.g. "02" or "Chapter two"
statoptional{ value: number; format?: Intl.NumberFormatOptions; label: string; suffix?: string }with-stat: the figure and what it means
highlightoptionalstringstatement: a phrase inside the title in the accent color
asoptional'h2' | 'h3'childrenoptionalReact.ReactNodeThe section content. sticky-side places it in the right column, the others below the head.
Eingebaute Texte
Diese Texte schreibt Section Header selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 2 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| filters | Englisch Filter | Deutsch Filter |
| progress | Englisch Reading progress of this section | Deutsch Lesefortschritt dieses Abschnitts |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Section Header reagiert selbst auf die Tasten Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts und Leertaste.
- Setzt aria-checked und aria-label, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen radio und radiogroup.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- 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. 5 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 375 Zeilen
'use client';
import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useInView, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { NumberTicker } from '@/components/ui/number-ticker';
import { WordReveal } from '@/components/ui/word-reveal';
import { useScrollProgress } from '@/components/ui/motion';
/*
* Heads for sections that carry more than a title. The plain left, centered
* and split heads live in section.tsx (SectionHeader). These six add what a
* section often needs next to its title:
*
* with-link a "view all" link on the title's baseline, for rows and carousels
* with-filters filter chips with a gliding marker that drive the content below
* chapter a chapter mark, a rule that draws across while you scroll, title and text in two columns
* with-stat title and text with one big figure that counts up
* sticky-side the head stands in a side column while the content scrolls past, with a progress line
* statement one large sentence whose words light up as you read (once per page)
*
* Server-rendered in the final state. Scroll motion reads --kit-progress, so
* without JavaScript and with reduced motion everything stands finished.
*/
export type SectionHeadingVariant = 'with-link' | 'with-filters' | 'chapter' | 'with-stat' | 'sticky-side' | 'statement';
export interface SectionHeadingProps {
variant?: SectionHeadingVariant;
eyebrow?: string;
/** For statement a plain string, its words light up one after another */
title: React.ReactNode;
description?: React.ReactNode;
/** with-link (and optional on the others): a link next to the title */
link?: { label: string; href: string };
/** with-filters */
filters?: string[];
defaultFilter?: number;
onFilterChange?: (index: number) => void;
/** chapter: the mark, e.g. "02" or "Chapter two" */
mark?: string;
/** with-stat: the figure and what it means */
stat?: { value: number; format?: Intl.NumberFormatOptions; label: string; suffix?: string };
/** statement: a phrase inside the title in the accent color */
highlight?: string;
as?: 'h2' | 'h3';
/** The section content. sticky-side places it in the right column, the others below the head. */
children?: React.ReactNode;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: { filters: 'Filter', progress: 'Reading progress of this section' },
de: { filters: 'Filter', progress: 'Lesefortschritt dieses Abschnitts' },
};
const TITLE = 'text-balance text-h2 font-semibold text-foreground';
const EYEBROW = 'mb-4 text-sm font-medium text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]';
const RISE = 'transition-[opacity,transform,filter] duration-700 ease-[var(--ease-kit)] motion-reduce:transition-none';
/** Fades and lifts its children in when the head scrolls into view */
function useEnter() {
const [ref, inView] = useInView<HTMLDivElement>();
const hidden = !inView;
const at = (i: number): { className: string; style: React.CSSProperties } => ({
className: cn(RISE, hidden && 'translate-y-3 opacity-0 blur-[2px]'),
style: { transitionDelay: hidden ? '0ms' : `${i * 80}ms` },
});
return [ref, at] as const;
}
function ViewAll({ link, className }: { link: NonNullable<SectionHeadingProps['link']>; className?: string }) {
return (
<a
href={link.href}
className={cn(
'group/link inline-flex h-11 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md text-[15px] font-medium text-foreground outline-none focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)]',
className,
)}
>
<span className="relative">
{link.label}
<span aria-hidden className="absolute inset-x-0 -bottom-0.5 h-px origin-left scale-x-0 bg-current transition-transform duration-500 ease-[var(--ease-kit)] group-hover/link:scale-x-100 motion-reduce:transition-none" />
</span>
<span className="grid size-7 place-items-center rounded-full border border-border bg-surface text-muted-foreground transition-[transform,background-color,color,border-color] duration-300 ease-[var(--ease-kit)] group-hover/link:translate-x-1 group-hover/link:border-transparent group-hover/link:bg-primary group-hover/link:text-primary-foreground motion-reduce:transition-none">
<Icon name="arrow-right" size={14} />
</span>
</a>
);
}
function Filters({ filters, value, onChange, label }: { filters: string[]; value: number; onChange: (i: number) => void; label: string }) {
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const row = useRef<HTMLDivElement>(null);
const [box, setBox] = useState<{ x: number; w: number; total: number; glide: boolean } | null>(null);
const measure = (i: number) => {
const el = refs.current[i];
const r = row.current;
if (el && r) setBox((b) => ({ x: el.offsetLeft, w: el.offsetWidth, total: r.scrollWidth, glide: b !== null }));
};
// The marker is measured once the fonts are in, until then the active chip paints itself
useEffect(() => {
const go = () => measure(value);
go();
document.fonts?.ready.then(go).catch(() => {});
window.addEventListener('resize', go);
return () => window.removeEventListener('resize', go);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
const move = (to: number) => {
const n = (to + filters.length) % filters.length;
onChange(n);
refs.current[n]?.focus();
refs.current[n]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
};
return (
<div ref={row} role="radiogroup" aria-label={label} className="kit-no-scrollbar relative -mx-1 flex max-w-full gap-1 overflow-x-auto px-1 py-1">
{box && (
<span
aria-hidden
className={cn('pointer-events-none absolute top-1 bottom-1 left-0 bg-foreground motion-reduce:transition-none', box.glide && 'transition-[clip-path] duration-500 ease-[var(--ease-kit)]')}
style={{ width: box.total, clipPath: `inset(0 ${box.total - box.x - box.w}px 0 ${box.x}px round 999px)` }}
/>
)}
{filters.map((f, i) => (
<button
key={f}
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 h-11 shrink-0 whitespace-nowrap rounded-full border px-4 text-sm font-medium outline-none transition-[color,border-color,background-color] duration-300 focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ring)] sm:h-10',
i === value
? cn('border-transparent text-background', !box && 'bg-foreground')
: 'border-border text-muted-foreground hover:border-border-strong hover:text-foreground',
)}
>
{f}
</button>
))}
</div>
);
}
/**
* Reading progress of a section into --kit-progress: 0 when its top passes 30 %
* of the window, 1 when its end has been read. At the end of the page the end may
* never reach that line, then the section counts as read once its end is in view.
*/
function useReadProgress<T extends HTMLElement>() {
const ref = useRef<T>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (prefersReducedMotion()) {
el.style.setProperty('--kit-progress', '1');
return;
}
let frame = 0;
const update = () => {
frame = 0;
const r = el.getBoundingClientRect();
const h = window.innerHeight;
const atEnd = h + window.scrollY >= document.documentElement.scrollHeight - 2;
const p = atEnd && r.bottom <= h + 1 ? 1 : Math.min(1, Math.max(0, (h * 0.3 - r.top) / Math.max(1, r.height - h * 0.4)));
el.style.setProperty('--kit-progress', p.toFixed(4));
};
const on = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', on, { passive: true });
window.addEventListener('resize', on);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('scroll', on);
window.removeEventListener('resize', on);
};
}, []);
return ref;
}
/**
* A section head with a link, filters, a chapter mark, a figure, a sticky side
* column or a statement that lights up while you read.
* <SectionHeading variant="with-link" title="Latest stories" link={{ label: 'All stories', href: '/blog' }}>…</SectionHeading>
*/
export function SectionHeading({ variant = 'with-link', eyebrow, title, description, link, filters = [], defaultFilter = 0, onFilterChange, mark, stat, highlight, as: Tag = 'h2', children, labels, className }: SectionHeadingProps) {
const t = useTexts(TEXTS, labels);
const [ref, at] = useEnter();
const [filter, setFilter] = useState(defaultFilter);
const progress = useScrollProgress<HTMLDivElement>({ start: 0.05, end: 0.4 });
const side = useReadProgress<HTMLDivElement>();
const pickFilter = (i: number) => {
setFilter(i);
onFilterChange?.(i);
};
if (variant === 'statement') {
const text = typeof title === 'string' ? title : '';
return (
<div className={cn('max-w-[58rem]', className)}>
{eyebrow && <p className={EYEBROW}>{eyebrow}</p>}
{text ? (
<WordReveal as={Tag} text={text} trigger="scroll" highlight={highlight} scrollRange={[0.1, 0.55]} className="text-balance text-[clamp(1.875rem,1.2rem+2.6vw,3.25rem)] font-semibold leading-[1.12] tracking-[-0.025em] text-foreground" />
) : (
<Tag className="text-balance text-[clamp(1.875rem,1.2rem+2.6vw,3.25rem)] font-semibold leading-[1.12] tracking-[-0.025em] text-foreground">{title}</Tag>
)}
{(description || link) && (
<div className="mt-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:gap-8">
{description && <p className="max-w-[32rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
{link && <ViewAll link={link} />}
</div>
)}
{children && <div className="mt-12 sm:mt-16">{children}</div>}
</div>
);
}
if (variant === 'sticky-side') {
return (
<div ref={side} className={cn('grid grid-cols-1 gap-10 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-16', className)}>
<div className="lg:relative">
<div ref={ref} className="lg:sticky lg:top-24">
{eyebrow && <p {...at(0)} className={cn(EYEBROW, at(0).className)}>{eyebrow}</p>}
<Tag {...at(1)} className={cn(TITLE, at(1).className)}>
{title}
</Tag>
{description && (
<p {...at(2)} className={cn('mt-5 max-w-[28rem] text-pretty text-lead text-muted-foreground', at(2).className)}>
{description}
</p>
)}
{link && (
<div {...at(3)} className={cn('mt-6', at(3).className)}>
<ViewAll link={link} />
</div>
)}
<div aria-hidden className="mt-10 hidden h-px w-full max-w-[16rem] overflow-hidden bg-border lg:block">
<div className="h-full w-full origin-left bg-primary" style={{ transform: 'scaleX(var(--kit-progress, 1))' }} />
</div>
</div>
</div>
<div className="min-w-0">{children}</div>
</div>
);
}
if (variant === 'chapter') {
return (
<div ref={progress} className={cn('', className)}>
<div ref={ref} className="flex items-center gap-4">
{mark && (
<span {...at(0)} className={cn('shrink-0 text-[13px] font-semibold tabular-nums leading-none tracking-[0.04em] text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]', at(0).className)}>
{mark}
</span>
)}
<span aria-hidden className="h-px min-w-0 flex-1 origin-left bg-border-strong motion-reduce:transform-none" style={{ transform: 'scaleX(var(--kit-progress, 1))' }} />
{eyebrow && <span className="shrink-0 text-[13px] leading-none text-muted-foreground">{eyebrow}</span>}
</div>
<div className="mt-8 grid grid-cols-1 gap-5 sm:mt-10 lg:grid-cols-2 lg:items-end lg:gap-16">
<Tag {...at(1)} className={cn(TITLE, at(1).className)}>
{title}
</Tag>
{(description || link) && (
<div {...at(2)} className={cn('lg:pb-1.5', at(2).className)}>
{description && <p className="max-w-[32rem] text-pretty text-lead text-muted-foreground">{description}</p>}
{link && <ViewAll link={link} className="mt-4" />}
</div>
)}
</div>
{children && <div className="mt-12 sm:mt-14">{children}</div>}
</div>
);
}
if (variant === 'with-stat') {
return (
<div className={className}>
<div ref={ref} className="grid grid-cols-1 gap-8 border-b border-border pb-10 sm:pb-12 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end lg:gap-16">
<div className="min-w-0">
{eyebrow && <p {...at(0)} className={cn(EYEBROW, at(0).className)}>{eyebrow}</p>}
<Tag {...at(1)} className={cn(TITLE, 'max-w-[40rem]', at(1).className)}>
{title}
</Tag>
{description && (
<p {...at(2)} className={cn('mt-5 max-w-[34rem] text-pretty text-lead text-muted-foreground', at(2).className)}>
{description}
</p>
)}
</div>
{stat && (
<div {...at(3)} className={cn('flex items-end gap-4 lg:flex-col lg:items-end lg:gap-2 lg:text-right', at(3).className)}>
<p className="text-[clamp(3rem,2.2rem+3vw,4.75rem)] font-semibold leading-[0.9] tracking-[-0.04em] text-foreground tabular-nums">
<NumberTicker value={stat.value} format={stat.format} duration={1600} />
{stat.suffix && <span className="kit-accent-text">{stat.suffix}</span>}
</p>
<p className="max-w-[14rem] pb-1 text-[13px] leading-snug text-muted-foreground lg:pb-0">{stat.label}</p>
</div>
)}
</div>
{children && <div className="mt-10 sm:mt-12">{children}</div>}
</div>
);
}
if (variant === 'with-filters') {
return (
<div className={className}>
<div ref={ref} className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between lg:gap-12">
<div className="min-w-0">
{eyebrow && <p {...at(0)} className={cn(EYEBROW, at(0).className)}>{eyebrow}</p>}
<Tag {...at(1)} className={cn(TITLE, at(1).className)}>
{title}
</Tag>
{description && (
<p {...at(2)} className={cn('mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground', at(2).className)}>
{description}
</p>
)}
</div>
{filters.length > 0 && (
<div {...at(3)} className={cn('min-w-0 shrink-0', at(3).className)}>
<Filters filters={filters} value={filter} onChange={pickFilter} label={t.filters} />
</div>
)}
</div>
{children && <div className="mt-8 sm:mt-10">{children}</div>}
</div>
);
}
/* with-link */
return (
<div ref={progress} className={className}>
<div ref={ref} className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between sm:gap-10">
<div className="min-w-0">
{eyebrow && <p {...at(0)} className={cn(EYEBROW, at(0).className)}>{eyebrow}</p>}
<Tag {...at(1)} className={cn(TITLE, at(1).className)}>
{title}
</Tag>
{description && (
<p {...at(2)} className={cn('mt-4 max-w-[34rem] text-pretty text-lead text-muted-foreground', at(2).className)}>
{description}
</p>
)}
</div>
{link && (
<div {...at(3)} className={cn('-mb-2 sm:-mb-1', at(3).className)}>
<ViewAll link={link} />
</div>
)}
</div>
<div aria-hidden className="mt-8 h-px bg-border sm:mt-10">
<div className="h-px origin-left bg-[linear-gradient(90deg,var(--primary),color-mix(in_oklab,var(--primary)_10%,transparent))]" style={{ transform: 'scaleX(var(--kit-progress, 1))' }} />
</div>
{children && <div className="mt-8 sm:mt-10">{children}</div>}
</div>
);
}Häufige Fragen
Wie installiere ich Section Header?
Mit aiw add @ai-website/section-header. Der Befehl legt die Datei components/ui/section-header.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, number-ticker, word-reveal und motion. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Section Header?
6 Varianten, Mit Link, Mit Filtern, Kapitel, Mit Kennzahl, Klebende Seitenspalte und Statement. Du wählst sie mit der Prop variant, zum Beispiel variant="with-filters".
Kann ich die Texte von Section Header übersetzen?
Ja. Section Header bringt 2 eigene Texte auf Deutsch und Englisch mit, etwa „Filter“ und „Lesefortschritt dieses Abschnitts“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Section Header einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Section Header mit aiw_components und installieren sie mit aiw_add @ai-website/section-header. Nenn im Prompt die Variante, etwa variant="with-link", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Section Header ohne Code anpassen?
Ja, im Studio. Alle 6 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden SectionHeading-Code zum Kopieren.