Skip to content
UI Kit
Accent color

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

6 variants

Variants

With filters

Filter chips beside the title. The dark marker glides to the choice, arrow keys move it and the grid below rises in anew.

Open in Studio

Chapter

For long pages in parts. The chapter mark drifts, a rule draws across while you scroll, title and text sit in two columns.

Open in Studio

With figure

Title and text with one big figure on the right that counts up as it scrolls in. On phones the figure sits under the text.

Open in Studio

Sticky side

On desktops the head stands in its column while the content scrolls past, a line under it fills with the reading. Phones stack head and content.

Open in Studio

Statement

One large sentence whose words light up one after another as you read, a phrase in the accent. Use it once per page.

Open in Studio

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

Props

The SectionHeadingProps interface from components/ui/section-header.tsx, with the comments from the code.

  • variantoptional
    SectionHeadingVariant

    with-linkwith-filterschapterwith-statsticky-sidestatement

  • eyebrowoptional
    string
  • titlerequired
    React.ReactNode

    For statement a plain string, its words light up one after another

  • descriptionoptional
    React.ReactNode
  • linkoptional
    { label: string; href: string }

    with-link (and optional on the others): a link next to the title

  • filtersoptional
    string[]

    with-filters

  • defaultFilteroptional
    number
  • onFilterChangeoptional
    (index: number) => void
  • markoptional
    string

    chapter: 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

  • highlightoptional
    string

    statement: a phrase inside the title in the accent color

  • asoptional
    'h2' | 'h3'
  • childrenoptional
    React.ReactNode

    The 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
filtersEnglish FilterGerman Filter
progressEnglish Reading progress of this sectionGerman 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
components/ui/section-header.tsx
'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.

Often searched

Jump to

Copied to the clipboard