Scroll Story
MarketingA scrollytelling section — a pinned visual holds the viewport while steps scroll past and the visual switches to match. Continuous scroll becomes discrete, jitter-free steps via one rAF-throttled handler; the progress rail is scrubbed straight to the DOM, so smooth scrolling never re-renders. Sticky-based (no scroll-hijacking), reduced-motion aware, and it degrades to inline figures on mobile.
1280pxOpen
components/blocks/scroll-story.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* ScrollStory — a scrollytelling section: a pinned visual holds the viewport
* while a column of steps scrolls past, and the visual switches to match the
* step you're reading.
*
* The hard part is turning continuous scroll into *discrete, jitter-free* steps:
*
* - A single rAF-throttled `scroll`/`resize` handler reads geometry with
* `getBoundingClientRect` (no layout thrash, no per-pixel state).
* - The active step is whichever step's top has last crossed an activation line
* at mid-viewport. Because that derived index only *changes* at a crossing,
* React bails on same-value `setState` — so there's no flicker at boundaries.
* - The continuous progress rail is written straight to the DOM via a ref
* (`scaleY`), so smooth scrubbing never triggers a React re-render.
*
* The visual pins with `position: sticky` (no scroll-hijacking or JS layout).
* On small screens it degrades to each step carrying its own inline figure.
* Honors `prefers-reduced-motion`.
*/
interface Step {
id: string;
eyebrow: string;
title: string;
body: string;
}
const STEPS: Step[] = [
{
id: "capture",
eyebrow: "01 — Capture",
title: "Start with the raw signal",
body: "Every interaction, error, and metric streams in unshaped. On its own it's noise — but it's the ground truth everything else is built from.",
},
{
id: "structure",
eyebrow: "02 — Structure",
title: "Find the shape in the noise",
body: "Group, align, and connect. Patterns snap into a grid and the trend that was hiding in the scatter becomes something you can actually reason about.",
},
{
id: "ship",
eyebrow: "03 — Ship",
title: "Turn insight into an answer",
body: "Collapse the analysis into one confident result your users can act on — the moment all that work pays off.",
},
];
function clamp(n: number, min: number, max: number) {
return Math.min(max, Math.max(min, n));
}
/** A distinct monochrome figure per step, drawn with inline SVG (no deps). */
function Figure({ index }: { index: number }) {
const common = {
viewBox: "0 0 200 150",
fill: "none",
stroke: "currentColor",
"aria-hidden": true,
className: "h-full w-full text-zinc-950 dark:text-zinc-50",
} as const;
if (index === 0) {
// Scattered dots — raw, unstructured signal.
const dots = [
[28, 40],
[64, 96],
[46, 118],
[96, 34],
[120, 88],
[150, 52],
[172, 110],
[88, 70],
[136, 128],
[18, 82],
];
return (
<svg {...common}>
{dots.map(([x, y], i) => (
<circle
key={i}
cx={x}
cy={y}
r={4}
fill="currentColor"
stroke="none"
opacity={0.35 + (i % 4) * 0.2}
/>
))}
</svg>
);
}
if (index === 1) {
// Dots snapped to a grid + a trend line rising through them.
const cols = [30, 64, 98, 132, 166];
const rows = [40, 75, 110];
return (
<svg {...common}>
{rows.map((y) =>
cols.map((x) => (
<circle
key={`${x}-${y}`}
cx={x}
cy={y}
r={3}
fill="currentColor"
stroke="none"
opacity={0.25}
/>
)),
)}
<polyline
points="30,110 64,92 98,84 132,58 166,40"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
{[
[30, 110],
[64, 92],
[98, 84],
[132, 58],
[166, 40],
].map(([x, y], i) => (
<circle
key={i}
cx={x}
cy={y}
r={3.5}
fill="currentColor"
stroke="none"
/>
))}
</svg>
);
}
// One bold result with a check.
return (
<svg {...common}>
<rect
x={64}
y={39}
width={72}
height={72}
rx={18}
fill="currentColor"
stroke="none"
/>
<path
d="M84 75l12 12 20-24"
className="stroke-white dark:stroke-zinc-900"
strokeWidth={4}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function ScrollStory() {
const sectionRef = React.useRef<HTMLElement>(null);
const stepRefs = React.useRef<(HTMLLIElement | null)[]>([]);
const railRef = React.useRef<HTMLDivElement>(null);
const [active, setActive] = React.useState(0);
React.useEffect(() => {
let raf = 0;
const measure = () => {
raf = 0;
const section = sectionRef.current;
if (!section) return;
const vh = window.innerHeight;
// Continuous progress → straight to the DOM, no re-render.
const rect = section.getBoundingClientRect();
const travel = rect.height - vh;
const progress = travel > 0 ? clamp(-rect.top / travel, 0, 1) : 0;
if (railRef.current) {
railRef.current.style.transform = `scaleY(${progress})`;
}
// Discrete active step: the last one whose top has crossed mid-viewport.
const line = vh * 0.5;
let idx = 0;
for (let i = 0; i < stepRefs.current.length; i++) {
const el = stepRefs.current[i];
if (el && el.getBoundingClientRect().top <= line) idx = i;
}
setActive(idx); // React bails when idx is unchanged → no boundary flicker.
};
// Throttle to one measure per frame; the initial read also runs in rAF so
// no state is set synchronously inside the effect.
const onScroll = () => {
if (!raf) raf = requestAnimationFrame(measure);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
if (raf) cancelAnimationFrame(raf);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, []);
return (
<section
ref={sectionRef}
className="bg-zinc-50 text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50"
>
<div className="mx-auto max-w-6xl px-6">
<div className="grid md:grid-cols-2 md:gap-x-16">
{/* Pinned visual (md+). */}
<div className="hidden md:sticky md:top-0 md:flex md:h-screen md:items-center">
<div className="w-full">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
{STEPS.map((s, i) => (
<div
key={s.id}
data-active={active === i}
className={cn(
"absolute inset-0 grid place-items-center p-10 opacity-0 transition-opacity duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]",
"data-[active=true]:opacity-100 motion-reduce:transition-none",
)}
>
<Figure index={i} />
</div>
))}
{/* Continuous progress rail, scrubbed via ref. */}
<div className="absolute inset-y-0 left-0 w-1 bg-zinc-200 dark:bg-zinc-800">
<div
ref={railRef}
style={{ transform: "scaleY(0)", transformOrigin: "top" }}
className="h-full w-full bg-zinc-950 dark:bg-zinc-50"
/>
</div>
</div>
{/* Step indicator. */}
<div className="mt-6 flex items-center gap-2" aria-hidden="true">
{STEPS.map((s, i) => (
<span
key={s.id}
data-active={active === i}
className={cn(
"h-1.5 rounded-full bg-zinc-200 transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] dark:bg-zinc-800",
"dark:true]:bg-zinc-50 w-1.5 data-[active=true]:w-6 data-[active=true]:bg-zinc-950",
)}
/>
))}
</div>
</div>
</div>
{/* Scrolling steps. */}
<ol className="max-w-md">
{STEPS.map((s, i) => (
<li
key={s.id}
ref={(el) => {
stepRefs.current[i] = el;
}}
className="flex min-h-screen flex-col justify-center py-20"
>
<div
data-active={active === i}
className={cn(
"opacity-45 transition-opacity duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]",
"data-[active=true]:opacity-100 motion-reduce:opacity-100 motion-reduce:transition-none",
)}
>
<div className="font-mono text-xs tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
{s.eyebrow}
</div>
<h3 className="mt-3 text-2xl font-semibold tracking-tight text-zinc-950 sm:text-3xl dark:text-zinc-50">
{s.title}
</h3>
<p className="mt-3 leading-relaxed text-zinc-500 dark:text-zinc-400">
{s.body}
</p>
{/* Inline figure on small screens (no pinning there). */}
<div className="mt-6 aspect-[4/3] w-full overflow-hidden rounded-2xl border border-zinc-200 bg-white md:hidden dark:border-zinc-800 dark:bg-zinc-900">
<div className="grid size-full place-items-center p-10">
<Figure index={i} />
</div>
</div>
</div>
</li>
))}
</ol>
</div>
</div>
</section>
);
}Installation
Terminal
npx shadcn@latest add https://ui.saumyarex.xyz/r/scroll-story.jsonInstalls the block and its component dependencies in one step.
Install dependencies
Terminal
npm install clsx tailwind-mergeCopy the source
components/blocks/scroll-story.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* ScrollStory — a scrollytelling section: a pinned visual holds the viewport
* while a column of steps scrolls past, and the visual switches to match the
* step you're reading.
*
* The hard part is turning continuous scroll into *discrete, jitter-free* steps:
*
* - A single rAF-throttled `scroll`/`resize` handler reads geometry with
* `getBoundingClientRect` (no layout thrash, no per-pixel state).
* - The active step is whichever step's top has last crossed an activation line
* at mid-viewport. Because that derived index only *changes* at a crossing,
* React bails on same-value `setState` — so there's no flicker at boundaries.
* - The continuous progress rail is written straight to the DOM via a ref
* (`scaleY`), so smooth scrubbing never triggers a React re-render.
*
* The visual pins with `position: sticky` (no scroll-hijacking or JS layout).
* On small screens it degrades to each step carrying its own inline figure.
* Honors `prefers-reduced-motion`.
*/
interface Step {
id: string;
eyebrow: string;
title: string;
body: string;
}
const STEPS: Step[] = [
{
id: "capture",
eyebrow: "01 — Capture",
title: "Start with the raw signal",
body: "Every interaction, error, and metric streams in unshaped. On its own it's noise — but it's the ground truth everything else is built from.",
},
{
id: "structure",
eyebrow: "02 — Structure",
title: "Find the shape in the noise",
body: "Group, align, and connect. Patterns snap into a grid and the trend that was hiding in the scatter becomes something you can actually reason about.",
},
{
id: "ship",
eyebrow: "03 — Ship",
title: "Turn insight into an answer",
body: "Collapse the analysis into one confident result your users can act on — the moment all that work pays off.",
},
];
function clamp(n: number, min: number, max: number) {
return Math.min(max, Math.max(min, n));
}
/** A distinct monochrome figure per step, drawn with inline SVG (no deps). */
function Figure({ index }: { index: number }) {
const common = {
viewBox: "0 0 200 150",
fill: "none",
stroke: "currentColor",
"aria-hidden": true,
className: "h-full w-full text-zinc-950 dark:text-zinc-50",
} as const;
if (index === 0) {
// Scattered dots — raw, unstructured signal.
const dots = [
[28, 40],
[64, 96],
[46, 118],
[96, 34],
[120, 88],
[150, 52],
[172, 110],
[88, 70],
[136, 128],
[18, 82],
];
return (
<svg {...common}>
{dots.map(([x, y], i) => (
<circle
key={i}
cx={x}
cy={y}
r={4}
fill="currentColor"
stroke="none"
opacity={0.35 + (i % 4) * 0.2}
/>
))}
</svg>
);
}
if (index === 1) {
// Dots snapped to a grid + a trend line rising through them.
const cols = [30, 64, 98, 132, 166];
const rows = [40, 75, 110];
return (
<svg {...common}>
{rows.map((y) =>
cols.map((x) => (
<circle
key={`${x}-${y}`}
cx={x}
cy={y}
r={3}
fill="currentColor"
stroke="none"
opacity={0.25}
/>
)),
)}
<polyline
points="30,110 64,92 98,84 132,58 166,40"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
{[
[30, 110],
[64, 92],
[98, 84],
[132, 58],
[166, 40],
].map(([x, y], i) => (
<circle
key={i}
cx={x}
cy={y}
r={3.5}
fill="currentColor"
stroke="none"
/>
))}
</svg>
);
}
// One bold result with a check.
return (
<svg {...common}>
<rect
x={64}
y={39}
width={72}
height={72}
rx={18}
fill="currentColor"
stroke="none"
/>
<path
d="M84 75l12 12 20-24"
className="stroke-white dark:stroke-zinc-900"
strokeWidth={4}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export function ScrollStory() {
const sectionRef = React.useRef<HTMLElement>(null);
const stepRefs = React.useRef<(HTMLLIElement | null)[]>([]);
const railRef = React.useRef<HTMLDivElement>(null);
const [active, setActive] = React.useState(0);
React.useEffect(() => {
let raf = 0;
const measure = () => {
raf = 0;
const section = sectionRef.current;
if (!section) return;
const vh = window.innerHeight;
// Continuous progress → straight to the DOM, no re-render.
const rect = section.getBoundingClientRect();
const travel = rect.height - vh;
const progress = travel > 0 ? clamp(-rect.top / travel, 0, 1) : 0;
if (railRef.current) {
railRef.current.style.transform = `scaleY(${progress})`;
}
// Discrete active step: the last one whose top has crossed mid-viewport.
const line = vh * 0.5;
let idx = 0;
for (let i = 0; i < stepRefs.current.length; i++) {
const el = stepRefs.current[i];
if (el && el.getBoundingClientRect().top <= line) idx = i;
}
setActive(idx); // React bails when idx is unchanged → no boundary flicker.
};
// Throttle to one measure per frame; the initial read also runs in rAF so
// no state is set synchronously inside the effect.
const onScroll = () => {
if (!raf) raf = requestAnimationFrame(measure);
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
if (raf) cancelAnimationFrame(raf);
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, []);
return (
<section
ref={sectionRef}
className="bg-zinc-50 text-zinc-950 dark:bg-zinc-950 dark:text-zinc-50"
>
<div className="mx-auto max-w-6xl px-6">
<div className="grid md:grid-cols-2 md:gap-x-16">
{/* Pinned visual (md+). */}
<div className="hidden md:sticky md:top-0 md:flex md:h-screen md:items-center">
<div className="w-full">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-2xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-900">
{STEPS.map((s, i) => (
<div
key={s.id}
data-active={active === i}
className={cn(
"absolute inset-0 grid place-items-center p-10 opacity-0 transition-opacity duration-500 ease-[cubic-bezier(0.16,1,0.3,1)]",
"data-[active=true]:opacity-100 motion-reduce:transition-none",
)}
>
<Figure index={i} />
</div>
))}
{/* Continuous progress rail, scrubbed via ref. */}
<div className="absolute inset-y-0 left-0 w-1 bg-zinc-200 dark:bg-zinc-800">
<div
ref={railRef}
style={{ transform: "scaleY(0)", transformOrigin: "top" }}
className="h-full w-full bg-zinc-950 dark:bg-zinc-50"
/>
</div>
</div>
{/* Step indicator. */}
<div className="mt-6 flex items-center gap-2" aria-hidden="true">
{STEPS.map((s, i) => (
<span
key={s.id}
data-active={active === i}
className={cn(
"h-1.5 rounded-full bg-zinc-200 transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] dark:bg-zinc-800",
"dark:true]:bg-zinc-50 w-1.5 data-[active=true]:w-6 data-[active=true]:bg-zinc-950",
)}
/>
))}
</div>
</div>
</div>
{/* Scrolling steps. */}
<ol className="max-w-md">
{STEPS.map((s, i) => (
<li
key={s.id}
ref={(el) => {
stepRefs.current[i] = el;
}}
className="flex min-h-screen flex-col justify-center py-20"
>
<div
data-active={active === i}
className={cn(
"opacity-45 transition-opacity duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]",
"data-[active=true]:opacity-100 motion-reduce:opacity-100 motion-reduce:transition-none",
)}
>
<div className="font-mono text-xs tracking-wide text-zinc-500 uppercase dark:text-zinc-400">
{s.eyebrow}
</div>
<h3 className="mt-3 text-2xl font-semibold tracking-tight text-zinc-950 sm:text-3xl dark:text-zinc-50">
{s.title}
</h3>
<p className="mt-3 leading-relaxed text-zinc-500 dark:text-zinc-400">
{s.body}
</p>
{/* Inline figure on small screens (no pinning there). */}
<div className="mt-6 aspect-[4/3] w-full overflow-hidden rounded-2xl border border-zinc-200 bg-white md:hidden dark:border-zinc-800 dark:bg-zinc-900">
<div className="grid size-full place-items-center p-10">
<Figure index={i} />
</div>
</div>
</div>
</li>
))}
</ol>
</div>
</div>
</section>
);
}lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}