You Might Also Like
Scrollama Scrollytelling — Sticky Graphic Story Steps
Scrollama Scrollytelling · Scroll · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Scrollama Scrollytelling — Step Triggers Without a Scroll Listener

Scrollytelling — a graphic that stays put while text scrolls past and drives it — is the format behind most modern data journalism. The naive implementation is a scroll event listener that calls getBoundingClientRect() on every step on every scroll event, which fires hundreds of times a second and forces a layout recalculation each time. It works, and it is one of the most reliable ways to make a page feel sluggish.
Scrollama is a ~3kb wrapper around the Intersection Observer API. The browser watches the elements and tells you when a threshold is crossed, off the main thread. No scroll listener, no per-frame measurement, no throttling code to write.
The layout does most of the work
Scrollytelling is a CSS pattern before it is a JavaScript one:
.sss-scrolly { display: grid; grid-template-columns: 1fr 1fr } with .sss-sticky { position: sticky; top: 0; height: 100vh }
The graphic column sticks within the grid's bounds while the text column scrolls normally. height: 100vh is required — a sticky element with auto height sticks at whatever height its content happens to be, which is why these layouts so often drift out of vertical alignment.
Each step is min-height: 82vh, giving the reader roughly a screenful per beat. Too short and steps fire in rapid succession; too tall and the graphic sits unchanged long enough to feel broken. The padding: 30vh 0 on the step column lets the first and last steps sit centered rather than clamping to the edges.
offset is the most important setting
offset: 0.55
This is where in the viewport a step must reach before it counts as entered, as a fraction from the top. At 0 the step triggers when it touches the very top of the screen — the reader has not begun reading it and the graphic has already moved on. At 1 it triggers at the bottom, before the text is legible.
Just past halfway is the sweet spot: the reader is looking directly at the step as the graphic responds. Getting this wrong is the most common reason a scrollytelling piece feels disconnected from its own narrative.
Two callbacks, two jobs
onStepEnter fires once per step crossing and receives { element, index, direction }. Because index is supplied, the handler is a pure function of position — setActive(response.index) sets the current node, marks everything before it as done, and highlights the matching text. Discrete state, updated on discrete events.
onStepProgress fires continuously while a step is in view, giving progress from 0 to 1. That is for continuous state, here a progress bar:
((response.index * per + response.progress * per) * 100)
Each step owns 1 / steps.length of the bar, so the fill is the completed steps plus the fraction through the current one — a single continuous 0–100% across the whole story. Progress is opt-in (progress: true) because tracking it is more work than plain enter/exit events, so it stays off unless requested.
Three-state nodes
The diagram distinguishes upcoming, current, and completed rather than just on/off. Completed nodes stay at 60% opacity with a dimmed border instead of resetting to their inactive state, so scrolling back through the story shows a visible trail of where you have been. That is the small detail that makes the graphic feel like a narrative rather than a switch, and it costs one classList.toggle.
The resize call that is not optional
window.addEventListener('resize', function () { scroller.resize(); });
Scrollama measures every step's position when setup() runs and caches those offsets. A rotated phone, a resized window, or lazy-loaded content shifting the page all invalidate that cache, and without resize() every trigger point stays measured against a layout that no longer exists. Steps then fire at visibly wrong moments — a bug that never appears in desktop testing.
Reusing it
Add a .sss-step and a matching node and everything follows, since the handlers work from index and steps.length rather than hard-coded counts. The sticky graphic can be anything — a chart, a map, a canvas, a video scrubbed by progress. On narrow screens the grid collapses to one column, which is the honest mobile fallback: the graphic scrolls inline between steps rather than fighting for half a small screen. Compare with scroll pin steps for a GSAP-driven version, or scroll progress if you only need the bar.
Build with AI
Build, Understand, Optimize, and Extend It With AI
The mechanics here are simple but the tuning decisions are what separate a scrollytelling piece that reads well from one that feels off, so it is worth talking through. Paste the HTML, CSS, and JS into an AI assistant like Claude and ask it to explain what the offset value of 0.55 means in viewport terms, and describe how the experience changes at 0 and at 1 — then try both and feel the disconnect. Ask it to explain why onStepEnter and onStepProgress exist as separate callbacks and which kind of state each is suited to. Then ask what specifically breaks without the scroller.resize() call on window resize, and why that bug rarely appears during desktop development. For optimization, ask whether the sticky graphic needs will-change or containment as the diagram grows more complex, and how you would avoid layout thrash if each step also lazy-loaded an image. To extend it: have it scrub a video or a canvas animation from onStepProgress, add a direction check so scrolling up plays a different transition, add prefers-reduced-motion handling, or drive the steps from a data array. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a scrollytelling section with a sticky graphic and scrolling text steps using Scrollama (from a CDN, global scrollama factory) in plain HTML, CSS, and JavaScript.
Requirements:
- Lay it out as a two-column CSS grid: a sticky graphic column and a scrolling steps column, collapsing to a single column below about 820px. The sticky element must use position: sticky with top: 0 AND an explicit height of 100vh — explain that a sticky element with auto height sticks at its content height and ends up vertically misaligned.
- Give each text step a min-height of roughly 82vh so the reader gets about a screenful per beat, and pad the steps column top and bottom with around 30vh so the first and last steps can sit centered.
- The sticky graphic should be a vertical diagram of four labelled nodes connected by dashed SVG lines, plus a thin progress bar underneath.
- Set up Scrollama with step: '.your-step', offset: 0.55 and progress: true. Explain the offset value specifically: it is where in the viewport a step must reach before counting as entered, so 0 fires before the reader has begun reading and 1 fires before the text is legible — just past halfway means the reader is looking at the step as the graphic responds.
- Use onStepEnter for DISCRETE state: take response.index and set the matching diagram node to a current state, mark all EARLIER nodes as completed (a dimmed third state, not reset to inactive, so scrolling back shows a visible trail), and highlight the matching text step.
- Use onStepProgress for CONTINUOUS state: fill the progress bar across the WHOLE story by giving each step an equal share (1 / stepCount) and computing completed steps plus the fraction through the current one, so the bar runs smoothly from 0 to 100% across all steps rather than resetting per step.
- Derive everything from response.index and the step count — no hard-coded step numbers in the handlers — so adding a step and a node requires no JS changes.
- Add a window resize listener calling scroller.resize() and explain that Scrollama caches every step's measured offset during setup, so a rotated phone, resized window, or lazy-loaded content shifting the page leaves every trigger point measured against a stale layout, making steps fire at visibly wrong moments.
- Include a tall intro hero above and a footer below so there is real scroll runway, and style it dark with a cyan accent and smooth cubic-bezier transitions on the node states.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Add the Scrollama CDNInclude scrollama from the CDN panel — global scrollama factory.
- 2Paste HTML, CSS, and JSA sticky diagram renders beside four scrolling story steps.
- 3Scroll the storyEach step lights its matching node as it reaches mid-viewport.
- 4Watch the trailCompleted nodes stay dimmed rather than resetting, so progress is visible.
- 5Follow the baronStepProgress fills it continuously across the whole story.
- 6Add your own stepsAdd a step and a node — handlers derive from index and step count.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
A scroll listener fires hundreds of times per second and typically calls getBoundingClientRect on every step, forcing a layout recalculation each time — a reliable way to make a page feel sluggish. Scrollama wraps the Intersection Observer API, so the browser watches the elements and reports threshold crossings off the main thread, with no throttling code to write.
Where in the viewport a step must reach before it counts as entered, expressed as a fraction from the top. At 0 it fires when the step touches the very top, before the reader has begun reading it. At 1 it fires at the bottom, before the text is legible. Just past halfway means the reader is looking directly at the step as the graphic responds.
onStepEnter fires once per crossing and supplies the step index and direction, which suits discrete state like switching the active diagram node. onStepProgress fires continuously while a step is in view and supplies a 0-to-1 value, which suits continuous state like a progress bar or a scrubbed animation. Progress must be opted into with progress: true since tracking it costs more.
A position: sticky element with auto height sticks at whatever height its content happens to be, so the graphic ends up vertically misaligned and drifts as content changes. Setting height: 100vh makes it occupy the full viewport so the graphic can be reliably centered within it.
Scrollama measures each step position during setup and caches those offsets. Rotating a phone, resizing a window, or lazy-loaded content shifting the page all invalidate that cache, leaving trigger points measured against a layout that no longer exists — so steps fire at visibly wrong moments. Calling resize() re-measures them, and the bug rarely shows up in desktop testing.
Create the scroller in a mount effect after the steps have rendered, since setup measures the DOM, and call scroller.destroy() in cleanup so observers are not left watching detached nodes. Keep the active index in state and drive the classes declaratively from it rather than calling classList in the callback. Re-run setup if the number of steps changes.