Section header
Heads for sections that carry more than a title. A view all link on the baseline, filters that drive the grid, a chapter mark with a drawing rule, a big figure, a side column that stays while the content scrolls and a statement that lights up as you read.
One of 2 components in Page headers
Variants
Details
What Section header brings along, read from the registry and the source.
- Category
- Page headers
- Area
- Layout
- Variants
- 6
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/section-header.tsx
- Brings along
- utilskiticonnumber-tickerword-revealmotion
- npm packages
- None
- Languages
- English and German, 2 texts of its own
- Studio
- All 6 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SectionHeadingProps interface from components/ui/section-header.tsx, with the comments from the code.
variantoptionalSectionHeadingVariantwith-linkwith-filterschapterwith-statsticky-sidestatement
eyebrowoptionalstringtitlerequiredReact.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.
Built-in texts
Section header writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 2 texts
| Key | English | German |
|---|---|---|
| filters | English Filter | German Filter |
| progress | English Reading progress of this section | German Lesefortschritt dieses Abschnitts |
Accessibility
Read from the source of the component.
- Section header handles the keys Arrow up, Arrow down, Arrow left, Arrow right and Space itself.
- Sets aria-checked and aria-label, so screen readers announce purpose and state.
- Uses the roles radio and radiogroup.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- 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. 5 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, 375 lines
'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>
);
}Frequent questions
How do I install Section header?
With aiw add @ai-website/section-header. The command puts components/ui/section-header.tsx into your project, the code is yours from then on. It brings along icon, number-ticker, word-reveal and motion. It needs no further npm packages.
Which variants does Section header have?
6 variants, With link, With filters, Chapter, With figure, Sticky side and Statement. You pick one with the variant prop, for example variant="with-filters".
Can I translate the texts of Section header?
Yes. Section header carries 2 texts of its own in English and German, such as “Filter” and “Reading progress of this section”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Section header?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Section header with aiw_components and install it with aiw_add @ai-website/section-header. Name the variant in your prompt, for example variant="with-link", and the assistant writes your content straight into the props.
Can I adjust Section header without code?
Yes, in the Studio. All 6 variants are editable there, texts, lists and switches. The Studio writes the matching SectionHeading code to copy.