scrolly
Scrollytelling stage: a pinned figure that updates as prose steps scroll past, unpinning below 1024px.
figurescrollytellinglayout
Preview
one — 0%
Step one: the figure shows state A.
Scroll down. The pinned figure on the right tracks whichever step crosses the middle of the viewport.
two — 50%
Step two: the figure shows state B.
Each step is just prose plus a key and caption — the figure is whatever the render prop returns.
three — 100%
Step three: the figure shows state C.
Below 1024px the figure unpins and each step renders its own inline snapshot instead.
Installation
shadcn CLI
Terminal
npx shadcn@latest add https://mocoui.site/r/scrolly.jsonManual
Copy each file from the Source section into its target path:
registry/scrolly/scrolly.tsx → components/moco/scrolly.tsx
registry/scrolly/scrolly.css → components/moco/scrolly.cssNeeds moco items: tokens
Usage
usage.tsx
import { ScrollyStage, type StepShape } from "./scrolly";
const STEPS: StepShape[] = [
{
key: "one",
caption: "Step one: the figure shows state A.",
text: <p>Scroll down. The pinned figure on the right tracks whichever step crosses the middle of the viewport.</p>,
},
{
key: "two",
caption: "Step two: the figure shows state B.",
text: <p>Each step is just prose plus a key and caption — the figure is whatever the render prop returns.</p>,
},
{
key: "three",
caption: "Step three: the figure shows state C.",
text: <p>Below 1024px the figure unpins and each step renders its own inline snapshot instead.</p>,
},
];
export default function ScrollyDemo() {
return (
<ScrollyStage
steps={STEPS}
render={(step, i, progress) => (
<div
style={{
display: "grid",
placeItems: "center",
height: 160,
fontFamily: "var(--moco-mono)",
color: "var(--moco-ink)",
}}
>
{step.key} — {Math.round(progress * 100)}%
</div>
)}
/>
);
}
Source
components/moco/scrolly.tsx
"use client";
import * as React from "react";
import "./scrolly.css";
export interface StepShape {
key: string;
caption: string;
text: React.ReactNode;
}
export interface ScrollyStageProps<S extends StepShape> {
steps: S[];
render: (step: S, index: number, progress: number) => React.ReactNode;
initial?: number;
}
/**
* A figure that pins to the viewport while prose steps scroll past it.
* Below 1024px it un-pins and each step renders its own static snapshot.
* With JS off, every step is visible and the figure shows the final state
* (add a `no-js` class to <html> and remove it with JS to get that fallback).
*/
export function ScrollyStage<S extends StepShape>({
steps,
render,
initial = 0,
}: ScrollyStageProps<S>) {
const [active, setActive] = React.useState(initial);
const refs = React.useRef<(HTMLDivElement | null)[]>([]);
React.useEffect(() => {
const io = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
const i = Number((e.target as HTMLElement).dataset.step);
if (!Number.isNaN(i)) setActive(i);
}
},
// Fire when a step crosses the middle band of the viewport.
{ rootMargin: "-45% 0px -45% 0px", threshold: 0 },
);
refs.current.forEach((el) => el && io.observe(el));
return () => io.disconnect();
}, [steps.length]);
const current = steps[Math.min(active, steps.length - 1)];
return (
<div className="moco-scrolly">
<div className="moco-scrolly-figure" aria-hidden="true">
<div className="moco-plate">
{render(current, active, active / Math.max(1, steps.length - 1))}
</div>
<p className="moco-step-cap">{current.caption}</p>
</div>
<div className="moco-scrolly-steps">
{steps.map((s, i) => (
<div
key={s.key}
data-step={i}
data-on={i === active}
className="moco-scrolly-step"
ref={(el) => {
refs.current[i] = el;
}}
>
{/* Mobile / no-JS snapshot, above its own paragraph. */}
<div className="moco-scrolly-inline">
<div className="moco-plate">
{render(s, i, i / Math.max(1, steps.length - 1))}
</div>
<p className="moco-step-cap">{s.caption}</p>
</div>
<div>{s.text}</div>
</div>
))}
</div>
</div>
);
}
components/moco/scrolly.css
.moco-scrolly {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 420px);
gap: 3rem;
align-items: start;
margin-block: 4rem;
/* Fills its container. Give it a wide one — the pinned figure plus a
420px prose rail want room to breathe. */
width: 100%;
}
.moco-scrolly-figure {
position: sticky;
top: 22vh;
max-height: 62vh;
}
.moco-scrolly-steps {
display: grid;
gap: 45vh;
padding: 25vh 0;
}
.moco-scrolly-step {
transition: opacity 350ms var(--moco-ease);
opacity: 0.4;
}
.moco-scrolly-step[data-on="true"] {
opacity: 1;
}
.moco-scrolly-inline {
display: none;
}
@media (max-width: 1023px) {
.moco-scrolly {
display: block;
}
.moco-scrolly-figure {
display: none;
}
.moco-scrolly-steps {
display: block;
padding: 0;
}
.moco-scrolly-step {
opacity: 1;
margin-bottom: 3rem;
}
.moco-scrolly-inline {
display: block;
margin-bottom: 1rem;
}
}
/* No-JS: show every step and the static figure. Consumers set `no-js` on
<html> and remove it with JS. */
.no-js .moco-scrolly-step {
opacity: 1;
}
/* Figure plate the rendered snapshots sit on. */
.moco-plate {
position: relative;
background: var(--moco-panel);
border-radius: 2px;
padding: 2rem 1.75rem;
overflow-x: auto;
overflow-y: hidden;
background-image: radial-gradient(120% 90% at 50% 0%, rgba(0, 0, 0, 0.028), transparent 70%);
}
.moco-plate::-webkit-scrollbar {
height: 6px;
}
.moco-plate::-webkit-scrollbar-thumb {
background: var(--moco-line);
border-radius: 3px;
}
@media (max-width: 720px) {
.moco-plate {
padding: 1.25rem 1rem;
-webkit-mask-image: linear-gradient(to right, #000 93%, transparent 100%);
mask-image: linear-gradient(to right, #000 93%, transparent 100%);
}
}
.moco-step-cap {
color: var(--moco-muted);
font-size: 13px;
margin-top: 1rem;
min-height: 2.6em;
}