Skip to content
UI Kit
Accent color

Sticky split

Fixed content on the left, scrolling steps on the right. A counter, a checklist that ticks off, a contact beside answers or a changing year.

4 variantsOne of 5 components in Layouts

One side stays, the other tells

A sticky split keeps one side in place while the other scrolls past, so the context stays in view while the details arrive. On phones each version gets its own form, the checklist for example becomes a progress bar.

Steps with counter shows a large counter with a filling line and the current step on the left, the steps as cards on the right. Checklist is a sticky list that ticks off each step once it has been read. Contact beside answers keeps a heading and a contact card in place while a long list of answers scrolls past. Timeline with year changes a large year on the left with the events on the right, and the line fills as you read.

Use the counter for a process with four to six steps, the checklist for onboarding or a buying guide. Contact beside answers turns an FAQ into a sales page. Keep the fixed side short enough to fit a small laptop screen.

Variants

Steps with counterNew in 1.2

A large counter with a filling line and the current step on the left, the steps as cards on the right.

ChecklistNew in 1.2

A sticky list that ticks off each step once it has been read, a progress bar on phones.

Contact beside answersNew in 1.2

A heading and a contact card that stay while a long list of answers scrolls past.

Timeline with yearNew in 1.2

A large year on the left that changes with the events on the right, the line fills as you read.

Details

What Sticky split brings along, read from the registry and the source.

Category
Layouts
Area
Layout
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/sticky-split.tsx
Brings along
utilskiticonsectionrevealbutton
npm packages
None
Languages
English and German, 5 texts of its own
Studio
Adjust in code

Props

The StickySplitProps interface from components/ui/sticky-split.tsx, with the comments from the code.

  • variantoptional
    StickySplitVariant

    stepschecklistasidetimeline

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

    aside: the contact card on the left

  • actionoptional
    { label: string; href: string }

Built-in texts

Sticky split writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 5 texts
stepEnglish StepGerman Schritt
ofEnglish {a} of {b}German {a} von {b}
progressEnglish ProgressGerman Fortschritt
doneEnglish DoneGerman Erledigt
nowEnglish NowGerman Jetzt

Accessibility

Read from the source of the component.

  • Sets aria-current, so screen readers announce purpose and state.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • 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. 3 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, 370 lines
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>
  );
}

Frequent questions

How do I install Sticky split?

With aiw add @ai-website/sticky-split. The command puts components/ui/sticky-split.tsx into your project, the code is yours from then on. It brings along icon, section, reveal and button. It needs no further npm packages.

Which variants does Sticky split have?

4 variants, Steps with counter, Checklist, Contact beside answers and Timeline with year. You pick one with the variant prop, for example variant="checklist".

Can I translate the texts of Sticky split?

Yes. Sticky split carries 5 texts of its own in English and German, such as “Step”, “Progress” and “Done”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Sticky split?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Sticky split with aiw_components and install it with aiw_add @ai-website/sticky-split. Name the variant in your prompt, for example variant="steps", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard