Scroll story
A how-it-works section that tells itself as you scroll: steps light up at the middle, a rail fills, and one card reshapes into each step’s picture.
Install with the shadcn CLI
$ npx shadcn@latest add https://hairlineui.com/r/scroll-story.jsonOverview
Every product page has a "how it works", and it’s usually four numbered boxes nobody reads in order. Here the steps scroll past a picture that stays put. Whichever step crosses the middle of the view lights up while the rest wait at a third of their strength; a hairline down the side fills with the scroll and each step’s dot fills as the line reaches it. The picture is one card that reshapes to fit each step: its size eases from one to the next while the old picture blurs out and the new one rises in, and since each picture mounts when its step arrives, whatever it does (a switch flipping, a count rolling, a name typing itself) plays right then. "02 / 04" rolls above it. On a phone the picture sticks to the top and the steps slide under it. Tap a step to scroll it into place.
States
- reading line
- The middle of the view (wide), or 42% down what the sticky picture leaves (narrow). The last step whose top has crossed it is the active one.
- rail
- A 1px track from the first dot to the last; the fill follows the reading line on every scroll frame (written straight to the DOM, no re-render). Dots fill and the active one grows to 1.15× with a slight overshoot.
- steps
- Inactive steps at 35% opacity, the active one at full (400ms).
- picture
- The card eases its width and height to the new picture (460ms); the old one blurs out and shrinks to 0.97 (240ms) while the new one rises 10px out of a 4px blur (420ms, 90ms in). The counter rolls.
- narrow
- Under 520px of width the picture sticks to the top at 46% of the view with a hairline under it, and the steps scroll beneath.
Usage
import { ScrollStory } from "@/components/scroll-story";
<ScrollStory
steps={[
{ title: "Pick a template", body: "Start from a page that already moves.", visual: <PickTemplate /> },
{ title: "Make it yours", body: "Your name, your colour.", visual: <MakeYours /> },
{ title: "Go live", body: "Push it and watch the first visitors arrive.", visual: <GoLive /> },
]}
/>Works with
Pictures that play when their step arrives
Each visual mounts when its step becomes active, so a timer or a count inside it starts right then; no scroll code needed in the picture itself.
"use client";
import { useEffect, useState } from "react";
import { ScrollStory } from "@/components/scroll-story";
import { NumberRoll } from "@/components/number-roll";
function Visitors() {
const [count, setCount] = useState(0);
useEffect(() => {
const t = setTimeout(() => setCount(128), 300); // rolls up as the step arrives
return () => clearTimeout(t);
}, []);
return (
<div className="w-[240px] p-4">
<p className="text-[28px] font-semibold tabular-nums"><NumberRoll value={count} /></p>
<p className="text-[12px] text-muted-foreground">visitors in the first hour</p>
</div>
);
}
export function HowItWorks() {
return (
<section className="mx-auto max-w-5xl px-6">
<ScrollStory
side="end"
steps={[
{ title: "Connect your store", body: "Two clicks, no code.", visual: <Connect /> },
{ title: "Go live", body: "Watch the first visitors arrive.", visual: <Visitors /> },
]}
/>
</section>
);
}Props
| Prop | Type | Description |
|---|---|---|
| steps | { title: string; body?: ReactNode; visual: ReactNode }[] | In order. Give visuals a set width (the card sizes itself to them); keep them under about 280px wide for phones. |
| side | "start" | "end"default "end" | Which side the picture sits on when there’s room for two columns. |
| onStepChange | (index: number) => void | Runs when a different step becomes active. |
Accessibility and motion
- It finds the nearest scrolling box itself, so it works in the page or inside a scrolling panel; give the box a height.
- The active step has aria-current="step"; each title is a button that scrolls its step into place.
- Every step’s words stay in the page for reading and search; only the pictures come and go.
- Installs Number roll. With reduced motion the pictures swap in place and jumps don’t smooth-scroll.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.
The CLI also installs Number roll.
Source
›Show the full source of scroll-story.tsx (292 lines)
"use client";
import * as React from "react";
import { NumberRoll } from "./number-roll";
/* ─────────────────────────────────────────────────────────
* SCROLL STORY: a how-it-works that tells itself as you scroll
*
* steps the words scroll past a picture that stays put; the
* step crossing the middle of the view lights up and
* the others wait at a third of their strength
* rail a hairline down the steps fills with the scroll,
* continuously, and each step's dot fills as the line
* reaches it
* picture one card that reshapes to each step's picture: its
* size eases from one to the next while the old
* picture blurs out and the new one rises in (and
* plays whatever it plays when it arrives). "02 / 04"
* rolls above it
* narrow the picture sticks to the top and the steps slide
* underneath it
* jump tapping a step scrolls it to the middle
*
* Works with the page's scroll or inside any scrolling box.
* ───────────────────────────────────────────────────────── */
export interface ScrollStoryStep {
title: string;
body?: React.ReactNode;
/** Mounted when its step arrives, so any animation in it plays then. */
visual: React.ReactNode;
}
export interface ScrollStoryProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
steps: ScrollStoryStep[];
/** Where the picture sits when there's room for two columns. */
side?: "start" | "end";
onStepChange?: (index: number) => void;
}
const EASE = "cubic-bezier(0.16,1,0.3,1)";
const WIDE = 520;
const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
const query = window.matchMedia(reducedQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);
/** The nearest box that scrolls vertically, or null for the page. */
function scrollParent(el: HTMLElement | null) {
for (let p = el?.parentElement; p && p !== document.body; p = p.parentElement) {
if (/(auto|scroll)/.test(getComputedStyle(p).overflowY)) return p;
}
return null;
}
/** The card that changes shape between pictures. */
function Stage({ index, children, reduced }: { index: number; children: React.ReactNode; reduced: boolean }) {
const innerRef = React.useRef<HTMLDivElement>(null);
const [size, setSize] = React.useState<{ w: number; h: number } | null>(null);
const [ghost, setGhost] = React.useState<{ key: number; node: React.ReactNode } | null>(null);
const last = React.useRef({ index, node: children });
React.useLayoutEffect(() => {
const el = innerRef.current;
if (!el) return;
const measure = () => setSize({ w: el.offsetWidth, h: el.offsetHeight });
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [index]);
// A new picture: keep the old one a moment to blur out underneath, raise the new one.
React.useLayoutEffect(() => {
if (index === last.current.index) {
last.current.node = children;
return;
}
if (!reduced) setGhost({ key: last.current.index, node: last.current.node });
last.current = { index, node: children };
if (reduced) return;
innerRef.current?.animate(
[
{ opacity: 0, transform: "translateY(10px) scale(0.98)", filter: "blur(4px)" },
{ opacity: 1, transform: "none", filter: "blur(0px)" },
],
{ duration: 420, delay: 90, easing: EASE, fill: "backwards" },
);
}, [index, children, reduced]);
return (
<div
className="relative overflow-hidden rounded-[20px] border border-border bg-card"
style={{ width: size?.w, height: size?.h, transition: reduced || !size ? "none" : `width 460ms ${EASE}, height 460ms ${EASE}` }}
>
{ghost && (
<div
key={`ghost-${ghost.key}`}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 w-max"
ref={(el) => {
if (!el || el.dataset.out) return;
el.dataset.out = "1";
const a = el.animate(
[
{ opacity: 1, filter: "blur(0px)", transform: "none" },
{ opacity: 0, filter: "blur(4px)", transform: "scale(0.97)" },
],
{ duration: 240, easing: EASE, fill: "forwards" },
);
a.onfinish = () => setGhost((g) => (g?.key === ghost.key ? null : g));
}}
>
{ghost.node}
</div>
)}
<div key={index} ref={innerRef} className="w-max">
{children}
</div>
</div>
);
}
export function ScrollStory({ steps, side = "end", onStepChange, className = "", style, ...props }: ScrollStoryProps) {
const reduced = useReducedMotion();
const rootRef = React.useRef<HTMLDivElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const stickyRef = React.useRef<HTMLDivElement>(null);
const trackRef = React.useRef<HTMLSpanElement>(null);
const fillRef = React.useRef<HTMLSpanElement>(null);
const stepRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const dotRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const scroller = React.useRef<HTMLElement | null>(null);
const [active, setActive] = React.useState(0);
const [viewport, setViewport] = React.useState(0);
const activeRef = React.useRef(0);
const changed = React.useRef(onStepChange);
changed.current = onStepChange;
// Where the reading line is: the middle of the view, or the middle of what the sticky picture leaves.
const line = () => {
const box = scroller.current?.getBoundingClientRect();
const top = box ? box.top : 0;
const height = scroller.current ? scroller.current.clientHeight : window.innerHeight;
const wide = (rootRef.current?.clientWidth ?? 0) >= WIDE;
const stuck = wide ? 0 : (stickyRef.current?.offsetHeight ?? 0);
return top + stuck + (height - stuck) * (wide ? 0.5 : 0.42);
};
// On scroll: the rail fills to the line (written straight to the DOM), and the step at the line becomes active.
const update = React.useCallback(() => {
const list = listRef.current;
const first = dotRefs.current[0];
const last = dotRefs.current[steps.length - 1];
if (!list || !first || !last) return;
const y = line();
const origin = list.getBoundingClientRect().top;
const a = first.getBoundingClientRect().top + first.offsetHeight / 2 - origin;
const b = last.getBoundingClientRect().top + last.offsetHeight / 2 - origin;
if (trackRef.current) Object.assign(trackRef.current.style, { top: `${a}px`, height: `${b - a}px` });
if (fillRef.current) Object.assign(fillRef.current.style, { top: `${a}px`, height: `${Math.min(b - a, Math.max(0, y - origin - a))}px` });
let next = 0;
stepRefs.current.forEach((el, i) => {
if (el && el.getBoundingClientRect().top <= y) next = i;
});
if (next !== activeRef.current) {
activeRef.current = next;
setActive(next);
changed.current?.(next);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [steps.length]);
React.useLayoutEffect(() => {
const root = rootRef.current;
if (!root) return;
scroller.current = scrollParent(root);
const target: HTMLElement | Window = scroller.current ?? window;
let frame = 0;
const onScroll = () => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
update();
});
};
const measure = () => {
setViewport(scroller.current ? scroller.current.clientHeight : window.innerHeight);
onScroll();
};
measure();
target.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", measure);
const observer = new ResizeObserver(measure);
observer.observe(root);
if (scroller.current) observer.observe(scroller.current);
return () => {
cancelAnimationFrame(frame);
target.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", measure);
observer.disconnect();
};
}, [update]);
const jump = (i: number) => {
const el = stepRefs.current[i];
if (!el) return;
const delta = el.getBoundingClientRect().top - line() + 2;
const behavior: ScrollBehavior = reduced ? "auto" : "smooth";
if (scroller.current) scroller.current.scrollBy({ top: delta, behavior });
else window.scrollBy({ top: delta, behavior });
};
const pad = (n: number) => String(n).padStart(2, "0");
return (
<div
ref={rootRef}
className={`@container ${className}`}
style={{ ...style, ["--story-h" as string]: `${viewport || 600}px` }}
{...props}
>
<div className="grid @min-[520px]:grid-cols-2 @min-[520px]:gap-10">
{/* The picture: beside the steps when there's room, stuck to the top when there isn't. */}
<div
ref={stickyRef}
className={`sticky top-0 z-10 -mx-2 flex h-[calc(var(--story-h)*0.46)] flex-col items-center justify-center gap-3 border-b border-border bg-background px-2 @min-[520px]:mx-0 @min-[520px]:h-[var(--story-h)] @min-[520px]:border-b-0 @min-[520px]:bg-transparent @min-[520px]:px-0 ${
side === "end" ? "@min-[520px]:order-last" : ""
}`}
>
<p className="text-[11.5px] tabular-nums text-muted-foreground" aria-hidden="true">
<NumberRoll value={active + 1} format={{ minimumIntegerDigits: 2 }} duration={500} /> / {pad(steps.length)}
</p>
<Stage index={active} reduced={reduced}>
{steps[active]?.visual}
</Stage>
</div>
<div
ref={listRef}
className="relative pb-[calc(var(--story-h)*0.12)] pt-8 @min-[520px]:pt-[calc(var(--story-h)*0.38)]"
>
<span ref={trackRef} aria-hidden="true" className="absolute left-[5px] w-px bg-border" />
<span ref={fillRef} aria-hidden="true" className="absolute left-[5px] w-px bg-foreground" />
{steps.map((s, i) => {
const on = i === active;
const reached = i <= active;
return (
<div
key={i}
ref={(el) => {
stepRefs.current[i] = el;
}}
aria-current={on ? "step" : undefined}
className="relative min-h-[calc(var(--story-h)*0.42)] pl-7 @min-[520px]:min-h-[calc(var(--story-h)*0.55)]"
>
<span
ref={(el) => {
dotRefs.current[i] = el;
}}
aria-hidden="true"
className="absolute left-0 top-[3px] size-[11px] rounded-full border"
style={{
borderColor: reached ? "var(--foreground)" : "var(--border)",
backgroundColor: reached ? "var(--foreground)" : "var(--background)",
transform: on ? "scale(1.15)" : "scale(1)",
transition: reduced ? "none" : `background-color 240ms ${EASE}, border-color 240ms ${EASE}, transform 320ms cubic-bezier(0.34,1.36,0.64,1)`,
}}
/>
<div style={{ opacity: on ? 1 : 0.35, transition: reduced ? "none" : `opacity 400ms ${EASE}` }}>
<p className="text-[11.5px] leading-4 tabular-nums text-muted-foreground">{pad(i + 1)}</p>
<h3 className="mt-1.5 text-[18px] font-semibold leading-tight tracking-tight text-foreground @min-[520px]:text-[20px]">
<button type="button" onClick={() => jump(i)} className="rounded-[4px] text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40">
{s.title}
</button>
</h3>
{s.body && <div className="mt-2 max-w-[36ch] text-[13.5px] leading-relaxed text-muted-foreground">{s.body}</div>}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
Registry item: /r/scroll-story.json