Zum Inhalt springen
UI Kit
Accent

Sticky Split

Links fester Inhalt, rechts scrollende Schritte. Ein Zähler, eine Checkliste, die abhakt, ein Kontakt neben Antworten oder eine wechselnde Jahreszahl.

4 VariantsEine von 5 Components in Layouts

Eine Seite bleibt, die andere erzählt

Ein Sticky Split hält eine Seite fest, während die andere vorbeiscrollt, der Zusammenhang bleibt also im Blick, während die Details kommen. Am Handy bekommt jede Fassung eine eigene Form, die Checkliste etwa wird zum Fortschrittsbalken.

„Schritte mit Zähler“ zeigt links einen großen Zähler mit füllender Linie und dem aktuellen Schritt, rechts die Schritte als Karten. Die „Checkliste“ ist eine klebende Liste, die jeden Schritt abhakt, sobald er gelesen ist. „Kontakt neben Antworten“ hält Überschrift und Kontaktkarte fest, während eine lange Liste von Antworten vorbeizieht. „Zeitleiste mit Jahreszahl“ wechselt links eine große Jahreszahl mit den Ereignissen rechts, die Linie füllt sich beim Lesen.

Nimm den Zähler für einen Ablauf mit vier bis sechs Schritten, die Checkliste für Onboarding oder einen Kaufratgeber. „Kontakt neben Antworten“ macht aus einem FAQ eine Verkaufsseite. Halte die feste Seite so kurz, dass sie auch auf einen kleinen Laptop passt.

Variants

Schritte mit ZählerNeu in 1.2

Links ein großer Zähler mit füllender Linie und dem aktuellen Schritt, rechts die Schritte als Karten.

ChecklisteNeu in 1.2

Eine klebende Liste, die jeden gelesenen Schritt abhakt, auf dem Telefon eine Fortschrittsleiste.

Kontakt neben AntwortenNeu in 1.2

Eine Überschrift und eine Kontaktkarte, die stehen bleiben, während eine lange Liste von Antworten vorbeizieht.

Zeitleiste mit JahreszahlNeu in 1.2

Links eine große Jahreszahl, die mit den Ereignissen rechts wechselt, die Linie füllt sich beim Lesen.

Details

Was Sticky Split mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Layouts
Bereich
Layout
Variants
4
Version
1.0.0 · seit 1.2.0
Datei
components/ui/sticky-split.tsx
Bringt mit
utilskiticonsectionrevealbutton
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 5 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle StickySplitProps aus components/ui/sticky-split.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    StickySplitVariant

    stepschecklistasidetimeline

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • stepsPflicht
    StickyStep[]
  • contactoptional
    StickySplitContact

    aside: the contact card on the left

  • actionoptional
    { label: string; href: string }

Eingebaute Texte

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

Alle 5 Texte anzeigen
stepEnglisch StepDeutsch Schritt
ofEnglisch {a} of {b}Deutsch {a} von {b}
progressEnglisch ProgressDeutsch Fortschritt
doneEnglisch DoneDeutsch Erledigt
nowEnglisch NowDeutsch Jetzt

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-current, damit Screenreader Zweck und Zustand ansagen.
  • 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. 3 Stellen im Code schalten Bewegung dann ab.

Quellcode

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

Datei anzeigen, 370 Zeilen
components/ui/sticky-split.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, round, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';
import { Reveal } from '@/components/ui/reveal';
import { Button } from '@/components/ui/button';

/*
 * Two columns: on the left what stays, on the right what scrolls past.
 * The left side follows the reader through the steps on the right.
 *
 *   steps      a counter with a filling line and the current step name
 *   checklist  a list that ticks off each step once it has been read
 *   aside      a heading and a contact card beside a long list of answers
 *   timeline   a large year that changes with the events on the right
 *
 * On phones the left side sits on top, steps, checklist and timeline keep
 * a slim sticky bar with the progress.
 */

export type StickySplitVariant = 'steps' | 'checklist' | 'aside' | 'timeline';

export interface StickyStep {
  title: string;
  text?: string;
  /** Small line above the title, e.g. a duration or a date */
  meta?: string;
  points?: string[];
  icon?: IconName;
}

export interface StickySplitContact {
  name: string;
  role?: string;
  text?: string;
  avatar?: React.ReactNode;
  action?: { label: string; href: string };
}

const TEXTS = {
  en: { step: 'Step', of: '{a} of {b}', progress: 'Progress', done: 'Done', now: 'Now' },
  de: { step: 'Schritt', of: '{a} von {b}', progress: 'Fortschritt', done: 'Erledigt', now: 'Jetzt' },
};
type Texts = (typeof TEXTS)['en'];

export interface StickySplitProps {
  variant?: StickySplitVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  steps: StickyStep[];
  /** aside: the contact card on the left */
  contact?: StickySplitContact;
  action?: { label: string; href: string };
  labels?: Partial<Texts>;
  className?: string;
}

const fill = (s: string, v: Record<string, string | number>) => Object.entries(v).reduce((a, [k, x]) => a.replace(`{${k}}`, String(x)), s);
const pad = (n: number) => String(n).padStart(2, '0');

/** The step whose top passed 45 % of the window, and how far through all steps the reader is */
function useActiveStep(count: number) {
  const refs = useRef<(HTMLElement | null)[]>([]);
  const list = useRef<HTMLOListElement>(null);
  const [active, setActive] = useState(0);
  const [progress, setProgress] = useState(0);
  useEffect(() => {
    let frame = 0;
    const update = () => {
      frame = 0;
      const line = window.innerHeight * 0.45;
      let i = 0;
      refs.current.slice(0, count).forEach((el, j) => {
        if (el && el.getBoundingClientRect().top <= line) i = j;
      });
      const l = list.current?.getBoundingClientRect();
      const atEnd = window.innerHeight + window.scrollY >= document.documentElement.scrollHeight - 2;
      if (atEnd && l && l.bottom < window.innerHeight) i = count - 1;
      setActive(i);
      if (l) setProgress(round(atEnd && l.top < line ? 1 : Math.min(1, Math.max(0, (line - l.top) / Math.max(1, l.height - window.innerHeight * 0.3))), 3));
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
    };
  }, [count]);
  return { refs, list, active, progress };
}

function Intro({ p, className }: { p: StickySplitProps; className?: string }) {
  return (
    <div className={className}>
      {p.eyebrow && <Eyebrow>{p.eyebrow}</Eyebrow>}
      {p.title && <h2 className="text-balance text-h2 font-semibold text-foreground">{p.title}</h2>}
      {p.description && <p className="mt-5 max-w-[30rem] text-pretty text-lead text-muted-foreground">{p.description}</p>}
    </div>
  );
}

function Points({ points }: { points?: string[] }) {
  if (!points?.length) return null;
  return (
    <ul className="mt-5 space-y-2">
      {points.map((pt) => (
        <li key={pt} className="flex gap-2.5 text-[15px] leading-relaxed text-foreground">
          <Icon name="check" size={16} strokeWidth={2.2} className="mt-1 shrink-0 text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]" />
          {pt}
        </li>
      ))}
    </ul>
  );
}

/** Slim bar for phones and tablets that keeps the progress in view */
function PhoneBar({ label, value, progress }: { label: React.ReactNode; value: React.ReactNode; progress: number }) {
  return (
    <div className="sticky top-0 z-20 -mx-6 mb-8 border-b border-border bg-background/90 px-6 py-3 backdrop-blur-md lg:hidden">
      <div className="flex items-center justify-between gap-4 text-[13px]">
        <span className="min-w-0 truncate font-medium text-foreground">{label}</span>
        <span className="shrink-0 tabular-nums text-muted-foreground">{value}</span>
      </div>
      <div aria-hidden className="mt-2 h-0.5 overflow-hidden rounded-full bg-track">
        <div className="h-full origin-left bg-primary transition-transform duration-300 motion-reduce:transition-none" style={{ transform: `scaleX(${progress})` }} />
      </div>
    </div>
  );
}

/* ------------------------------------------------------------------ */

function Steps({ p, t }: { p: StickySplitProps; t: Texts }) {
  const { refs, list, active, progress } = useActiveStep(p.steps.length);
  const step = p.steps[active];
  return (
    <div className="grid grid-cols-[minmax(0,1fr)] gap-x-16 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]">
      <div>
        <div className="lg:sticky lg:top-24">
          <Intro p={p} />
          <div className="mt-12 hidden lg:block" aria-hidden>
            <div className="flex items-baseline gap-3">
              <span key={active} className="text-[4.5rem] font-semibold leading-none tracking-[-0.05em] text-foreground tabular-nums motion-safe:animate-[kit-rise_0.5s_var(--ease-kit)]">{pad(active + 1)}</span>
              <span className="text-[18px] font-medium text-subtle-foreground tabular-nums">/ {pad(p.steps.length)}</span>
            </div>
            <div className="mt-6 h-px w-full max-w-[22rem] overflow-hidden bg-border">
              <div className="h-full origin-left bg-primary transition-transform duration-300 motion-reduce:transition-none" style={{ transform: `scaleX(${progress})` }} />
            </div>
            <p key={step?.title} className="mt-4 text-[15px] font-medium text-muted-foreground motion-safe:animate-[kit-rise_0.4s_var(--ease-kit)]">{step?.title}</p>
          </div>
          {p.action && (
            <div className="mt-10 hidden lg:block">
              <Button href={p.action.href} icon="arrow-right" magnetic>
                {p.action.label}
              </Button>
            </div>
          )}
        </div>
      </div>
      <div className="mt-12 lg:mt-0">
        <PhoneBar label={step?.title} value={fill(t.of, { a: active + 1, b: p.steps.length })} progress={progress} />
        <ol ref={list} className="space-y-5 lg:space-y-6 lg:pb-[20vh]">
          {p.steps.map((s, i) => {
            const on = i === active;
            return (
              <li key={s.title} ref={(el) => void (refs.current[i] = el)} aria-current={on ? 'step' : undefined}>
                <div className={cn('rounded-2xl border bg-card p-6 shadow-[var(--highlight),var(--shadow-sm)] transition-[border-color,box-shadow,opacity] duration-500 sm:p-8', on ? 'border-[color-mix(in_oklab,var(--primary)_45%,var(--border))] shadow-[var(--highlight),var(--shadow-md)]' : 'border-border')}>
                  <div className="flex items-center gap-3">
                    <span className={cn('flex size-9 items-center justify-center rounded-full border text-[13px] font-semibold tabular-nums transition-colors duration-500', on ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong text-muted-foreground')}>{i + 1}</span>
                    {s.meta && <span className="text-[13px] text-muted-foreground">{s.meta}</span>}
                  </div>
                  <h3 className="mt-5 text-balance text-[clamp(1.25rem,1.1rem+0.6vw,1.5rem)] font-semibold tracking-[-0.02em] text-foreground">{s.title}</h3>
                  {s.text && <p className="mt-3 max-w-[36rem] text-pretty text-[16px] leading-relaxed text-muted-foreground">{s.text}</p>}
                  <Points points={s.points} />
                </div>
              </li>
            );
          })}
        </ol>
        {p.action && (
          <div className="mt-10 lg:hidden">
            <Button href={p.action.href} icon="arrow-right" className="max-sm:w-full">
              {p.action.label}
            </Button>
          </div>
        )}
      </div>
    </div>
  );
}

function Checklist({ p, t }: { p: StickySplitProps; t: Texts }) {
  const { refs, list, active, progress } = useActiveStep(p.steps.length);
  const done = progress >= 0.999 ? p.steps.length : active;
  return (
    <div className="grid grid-cols-[minmax(0,1fr)] gap-x-16 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
      <div>
        <div className="lg:sticky lg:top-24">
          <Intro p={p} />
          <div className="mt-10 hidden overflow-hidden rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-md)] lg:block">
            <div className="flex items-center justify-between border-b border-border px-5 py-3.5 text-[13px]">
              <span className="font-medium text-foreground">{t.progress}</span>
              <span className="tabular-nums text-muted-foreground">{fill(t.of, { a: done, b: p.steps.length })}</span>
            </div>
            <ol className="p-2">
              {p.steps.map((s, i) => {
                const isDone = i < done;
                const on = i === active && !isDone;
                return (
                  <li key={s.title} className={cn('flex items-center gap-3 rounded-lg px-3 py-2.5 text-[14px] transition-colors duration-500', on && 'bg-accent')}>
                    <span className={cn('flex size-5 shrink-0 items-center justify-center rounded-md border transition-[background-color,border-color] duration-500', isDone ? 'border-success bg-success text-white' : on ? 'border-primary' : 'border-border-strong')}>
                      <Icon name="check" size={12} strokeWidth={2.8} className={cn('transition-[opacity,transform] duration-300', isDone ? 'scale-100 opacity-100' : 'scale-50 opacity-0')} />
                    </span>
                    <span className={cn('min-w-0 flex-1 truncate', isDone ? 'text-muted-foreground line-through decoration-border-strong' : on ? 'font-medium text-foreground' : 'text-foreground/85')}>{s.title}</span>
                    {on && <span className="text-[12px] font-medium text-primary">{t.now}</span>}
                  </li>
                );
              })}
            </ol>
          </div>
        </div>
      </div>
      <div className="mt-12 lg:mt-0">
        <PhoneBar label={p.steps[active]?.title} value={fill(t.of, { a: done, b: p.steps.length })} progress={progress} />
        <ol ref={list} className="divide-y divide-border border-y border-border lg:pb-[10vh]">
          {p.steps.map((s, i) => (
            <li key={s.title} ref={(el) => void (refs.current[i] = el)} className="py-10 first:pt-10 sm:py-12">
              <Reveal className="grid grid-cols-[auto_minmax(0,1fr)] gap-5">
                <span className="flex size-11 items-center justify-center rounded-xl border border-border bg-surface text-foreground">
                  <Icon name={s.icon ?? 'check-circle'} size={20} />
                </span>
                <div>
                  <p className="text-[13px] text-muted-foreground">
                    {t.step} {i + 1}
                    {s.meta && <span> · {s.meta}</span>}
                  </p>
                  <h3 className="mt-1 text-balance text-[clamp(1.25rem,1.1rem+0.6vw,1.5rem)] font-semibold tracking-[-0.02em] text-foreground">{s.title}</h3>
                  {s.text && <p className="mt-3 max-w-[36rem] text-pretty text-[16px] leading-relaxed text-muted-foreground">{s.text}</p>}
                  <Points points={s.points} />
                </div>
              </Reveal>
            </li>
          ))}
        </ol>
      </div>
    </div>
  );
}

function Aside({ p }: { p: StickySplitProps }) {
  const c = p.contact;
  return (
    <div className="grid grid-cols-[minmax(0,1fr)] gap-x-16 gap-y-12 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
      <div>
        <div className="lg:sticky lg:top-24">
          <Intro p={p} />
          {c && (
            <Reveal className="mt-10">
              <div className="rounded-2xl border border-border bg-surface p-5 shadow-[var(--highlight)] sm:p-6">
                <div className="flex items-center gap-4">
                  {c.avatar && <span data-clip-ok className="relative block size-12 shrink-0 overflow-hidden rounded-full bg-muted ring-1 ring-border [&>*]:size-full">{c.avatar}</span>}
                  <div className="min-w-0">
                    <p className="text-[15px] font-semibold text-foreground">{c.name}</p>
                    {c.role && <p className="text-[13px] text-muted-foreground">{c.role}</p>}
                  </div>
                </div>
                {c.text && <p className="mt-4 text-pretty text-[14.5px] leading-relaxed text-muted-foreground">{c.text}</p>}
                {c.action && (
                  <div className="mt-5">
                    <Button href={c.action.href} variant="outline" icon="arrow-right" className="w-full sm:w-auto">
                      {c.action.label}
                    </Button>
                  </div>
                )}
              </div>
            </Reveal>
          )}
        </div>
      </div>
      <div className="divide-y divide-border border-t border-border">
        {p.steps.map((s) => (
          <Reveal key={s.title} className="py-8 sm:py-10">
            <h3 className="text-balance text-[18px] font-semibold leading-snug tracking-[-0.015em] text-foreground">{s.title}</h3>
            {s.text && <p className="mt-3 max-w-[38rem] text-pretty text-[16px] leading-relaxed text-muted-foreground">{s.text}</p>}
            <Points points={s.points} />
          </Reveal>
        ))}
      </div>
    </div>
  );
}

function Timeline({ p, t }: { p: StickySplitProps; t: Texts }) {
  const { refs, list, active, progress } = useActiveStep(p.steps.length);
  // The rail ends at the last dot, not below the text of the last event
  const [tail, setTail] = useState(8);
  useEffect(() => {
    const el = list.current;
    if (!el) return;
    const measure = () => {
      const last = refs.current[p.steps.length - 1];
      if (last) setTail(Math.max(0, Math.round(el.offsetHeight - last.offsetTop - 14)));
    };
    measure();
    const ro = new ResizeObserver(measure);
    ro.observe(el);
    return () => ro.disconnect();
  }, [p.steps.length, list, refs]);
  const step = p.steps[active];
  return (
    <div className="grid grid-cols-[minmax(0,1fr)] gap-x-16 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]">
      <div>
        <div className="lg:sticky lg:top-24">
          <Intro p={p} />
          <div className="relative mt-10 hidden h-[clamp(6rem,4rem+6vw,9rem)] overflow-hidden lg:block" aria-hidden>
            <span key={step?.meta} className="kit-gradient-text absolute inset-x-0 top-0 block text-[clamp(6rem,4rem+6vw,9rem)] font-semibold leading-none tracking-[-0.06em] tabular-nums motion-safe:animate-[kit-rise_0.6s_var(--ease-kit)]">
              {step?.meta}
            </span>
          </div>
          <p key={step?.title} className="mt-2 hidden max-w-[24rem] text-[15px] font-medium text-muted-foreground motion-safe:animate-[kit-rise_0.45s_var(--ease-kit)] lg:block">{step?.title}</p>
        </div>
      </div>
      <div className="mt-12 lg:mt-0">
        <PhoneBar label={step?.meta ? `${step.meta} · ${step.title}` : step?.title} value={fill(t.of, { a: active + 1, b: p.steps.length })} progress={progress} />
        {/* the room after the last event sits outside the list, so the rail ends at the last dot */}
        <div className="lg:pb-[16vh]">
        <ol ref={list} className="relative">
          <span aria-hidden className="absolute left-[7px] top-2 w-px bg-border" style={{ bottom: tail }} />
          <span aria-hidden className="absolute left-[7px] top-2 w-px origin-top bg-primary transition-transform duration-300 motion-reduce:transition-none" style={{ height: `calc(100% - ${tail + 8}px)`, transform: `scaleY(${progress})` }} />
          {p.steps.map((s, i) => {
            const passed = i <= active;
            return (
              <li key={s.title} ref={(el) => void (refs.current[i] = el)} aria-current={i === active ? 'step' : undefined} className="relative pb-14 pl-10 last:pb-0">
                <span aria-hidden className={cn('absolute left-0 top-1.5 size-[15px] rounded-full border-2 transition-[background-color,border-color,transform] duration-500', passed ? 'border-primary bg-primary' : 'border-border-strong bg-background', i === active && 'scale-125 shadow-[0_0_0_5px_color-mix(in_oklab,var(--primary)_18%,transparent)]')} />
                <p className={cn('text-[14px] font-semibold tabular-nums transition-colors duration-500', passed ? 'text-primary' : 'text-subtle-foreground')}>{s.meta}</p>
                <h3 className="mt-1.5 text-balance text-[clamp(1.25rem,1.1rem+0.6vw,1.5rem)] font-semibold tracking-[-0.02em] text-foreground">{s.title}</h3>
                {s.text && <p className="mt-3 max-w-[36rem] text-pretty text-[16px] leading-relaxed text-muted-foreground">{s.text}</p>}
                <Points points={s.points} />
              </li>
            );
          })}
        </ol>
        </div>
      </div>
    </div>
  );
}

/**
 * Fixed content on the left, scrolling steps on the right: a counter, a
 * checklist that ticks off, a contact beside answers or a year that changes.
 *   <StickySplit variant="steps" title="…" steps={[{ title, text, meta, points }]} />
 */
export function StickySplit(props: StickySplitProps) {
  const { variant = 'steps', className, labels } = props;
  const t = useTexts(TEXTS, labels);
  return (
    <Section className={className}>
      {variant === 'checklist' ? <Checklist p={props} t={t} /> : variant === 'aside' ? <Aside p={props} /> : variant === 'timeline' ? <Timeline p={props} t={t} /> : <Steps p={props} t={t} />}
    </Section>
  );
}

Häufige Fragen

Wie installiere ich Sticky Split?

Mit aiw add @ai-website/sticky-split. Der Befehl legt die Datei components/ui/sticky-split.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, section, reveal und button. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Sticky Split?

4 Varianten, Schritte mit Zähler, Checkliste, Kontakt neben Antworten und Zeitleiste mit Jahreszahl. Du wählst sie mit der Prop variant, zum Beispiel variant="checklist".

Kann ich die Texte von Sticky Split übersetzen?

Ja. Sticky Split bringt 5 eigene Texte auf Deutsch und Englisch mit, etwa „Schritt“, „Fortschritt“ und „Erledigt“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Sticky Split einbauen?

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

Oft gesucht

Direkt zu

In die Zwischenablage kopiert