Zum Inhalt springen
UI Kit
Accent

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

6 Variants

Variants

Mit Filtern

Filter-Chips neben dem Titel. Die dunkle Markierung gleitet zur Wahl, Pfeiltasten bewegen sie und das Raster darunter steigt neu auf.

Im Studio öffnen

Kapitel

Für lange Seiten in Teilen. Die Kapitelmarke zieht leicht, eine Linie zeichnet sich beim Scrollen, Titel und Text stehen in zwei Spalten.

Im Studio öffnen

Mit Kennzahl

Titel und Text mit einer großen Kennzahl rechts, die beim Hereinscrollen hochzählt. Auf dem Telefon steht sie unter dem Text.

Im Studio öffnen

Klebende Seitenspalte

Auf dem Desktop bleibt der Kopf in seiner Spalte stehen, während der Inhalt vorbeizieht, eine Linie darunter füllt sich mit dem Lesen. Telefone stapeln Kopf und Inhalt.

Im Studio öffnen

Statement

Ein großer Satz, dessen Wörter beim Lesen nacheinander aufleuchten, eine Wendung im Akzent. Einmal pro Seite.

Im Studio öffnen

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

Props

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

  • variantoptional
    SectionHeadingVariant

    with-linkwith-filterschapterwith-statsticky-sidestatement

  • eyebrowoptional
    string
  • titlePflicht
    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.

Eingebaute Texte

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

Alle 2 Texte anzeigen
filtersEnglisch FilterDeutsch Filter
progressEnglisch Reading progress of this sectionDeutsch 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
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>
  );
}

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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert