webdevpuneet.com

Scrollama Scrollytelling: Sticky Story with Live Timeline

Scrollama Scrollytelling · Scroll · Plain HTML, CSS & JS · Live preview

Copy & paste

Source Code

Requires
<header class="sss-intro">
  <span class="sss-tag">scrollama · intersection observer</span>
  <h1>How a request becomes a page</h1>
  <p>Scroll. The graphic stays pinned while each step explains itself, and a packet travels the pipeline as you read.</p>
  <span class="sss-cue" aria-hidden="true">↓</span>
</header>

<section class="sss-scrolly" id="sssRoot">
  <figure class="sss-sticky">
    <figcaption class="sss-clock"><b id="sssClock">0</b><span>ms</span><em>to a usable page</em></figcaption>

    <div class="sss-diagram" id="sssDiagram">
      <div class="sss-node" data-node="0"><b>Browser</b><small>Request</small><em class="sss-chip">waiting</em></div>
      <div class="sss-node" data-node="1"><b>Edge</b><small>Cache</small><em class="sss-chip">waiting</em></div>
      <div class="sss-node" data-node="2"><b>Server</b><small>Render</small><em class="sss-chip">waiting</em><span class="sss-chunks" aria-hidden="true"><i></i><i></i><i></i><i></i></span></div>
      <div class="sss-node" data-node="3"><b>Client</b><small>Hydrate</small><em class="sss-chip">waiting</em></div>
      <i class="sss-packet" id="sssPacket" aria-hidden="true"></i>
    </div>

    <div class="sss-seg" role="img" aria-label="Time spent in each of the four steps">
      <span style="flex:120"><i></i></span><span style="flex:40"><i></i></span><span style="flex:380"><i></i></span><span style="flex:300"><i></i></span>
    </div>

    <nav class="sss-dots" aria-label="Story steps">
      <button type="button" class="sss-dot" data-go="0" aria-label="Go to step 1: The request leaves"></button>
      <button type="button" class="sss-dot" data-go="1" aria-label="Go to step 2: The edge answers first"></button>
      <button type="button" class="sss-dot" data-go="2" aria-label="Go to step 3: The server renders"></button>
      <button type="button" class="sss-dot" data-go="3" aria-label="Go to step 4: The client takes over"></button>
    </nav>
  </figure>

  <div class="sss-steps">
    <div class="sss-step" data-step="0" tabindex="0">
      <span class="sss-num">Step 1 · 120 ms</span>
      <h2>The request leaves</h2>
      <p>A URL is resolved, a connection is negotiated, and a single GET goes out. Everything after this is latency you are choosing to spend.</p>
    </div>
    <div class="sss-step" data-step="1" tabindex="0">
      <span class="sss-num">Step 2 · 40 ms</span>
      <h2>The edge answers first</h2>
      <p>If a cached copy exists within a few hundred kilometres, the origin is never contacted. This is the cheapest millisecond in the whole chain.</p>
    </div>
    <div class="sss-step" data-step="2" tabindex="0">
      <span class="sss-num">Step 3 · 380 ms</span>
      <h2>The server renders</h2>
      <p>On a miss, the origin builds the document: data fetched, components resolved, HTML streamed out as it becomes available rather than all at once.</p>
    </div>
    <div class="sss-step" data-step="3" tabindex="0">
      <span class="sss-num">Step 4 · 300 ms</span>
      <h2>The client takes over</h2>
      <p>Markup paints immediately, then JavaScript attaches behavior to what is already on screen. Done well, the user never sees the seam.</p>
    </div>
  </div>
</section>

<footer class="sss-foot">
  <p><b>840 ms</b> from click to a usable page <small>(illustrative timings)</small></p>
  <button type="button" class="sss-replay" id="sssReplay">↑ Replay the story</button>
</footer>
<p class="sss-sr" id="sssLive" aria-live="polite"></p>

Extend with AI

Take This Snippet Further With AI

This snippet is a good prompt for asking an AI to reason about scroll-driven state: paste it into an assistant like Claude and ask why both Scrollama callbacks call one render(index, progress) function, what would go wrong if onStepProgress alone drew the graphic when you drag the scrollbar to the end, and why onStepEnter and onStepExit recompute the state from the step positions instead of assuming progress 0. Then ask how the traveling packet avoids layout reads while you scroll (the node edge positions are measured once and cached) and why it rides the node edges instead of resting on a node centre, where it would cover the label and what has to be re-measured on resize. To extend it: ask for a map or chart in place of the pipeline, a second sticky element that scrubs a video from progress, a data-driven version that builds the steps and nodes from an array, or a "skip" link and a print stylesheet that shows every step as a normal article.

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:

text
Build a scrollytelling page with Scrollama (v3, global scrollama factory from a CDN) in plain HTML, CSS and JavaScript. The topic is a request travelling through Browser, Edge, Server and Client.

Requirements:
- A tall intro hero, then a two-column CSS grid: a sticky graphic column (position: sticky; top: 0; height: 100vh, content centred) and a steps column (four steps, min-height 82vh each, padding 30vh above and below). Below 820px the grid is one column and the graphic becomes a compact strip pinned to the top (nodes in a row, a translucent blurred background) while the steps scroll underneath.
- The graphic has a large millisecond clock, four nodes (Browser, Edge, Server, Client) each with a small status chip, real connector lines between them (a CSS gradient with a hard stop at calc(var(--w) * 100%), custom property --w from 0 to 1), a glowing packet dot, a four-segment waterfall bar whose segments are as wide as their step's milliseconds (120, 40, 380, 300), four small HTML "chunks" inside the Server node, and four dots to jump between steps.
- Set up Scrollama with step: '.step', offset: 0.55 and progress: true. Explain offset: a fraction from the top of the viewport where a step counts as entered; 0 is too early and 1 too late.
- Write ONE pure function render(index, progress) that draws everything: the clock (milliseconds of finished steps plus the current share), the waterfall fills, the packet (translate along the wires between cached node edge positions (exit edge of the previous node to the entry edge of the next, never the centre, so it cannot cover a label): Browser to Edge in step 1, held at Edge in step 2, Edge to Server in step 3, Server to Client in step 4), the wire fills, the chunks filling during step 3, and chips (done ✓, waiting, or a current label; the Edge chip changes from "cache lookup…" to "miss → origin" past 50%). Call it from onStepProgress, and from onStepEnter and onStepExit through a sync() function that finds the first step whose bottom is below the 55% line, derives the progress as (line - step top) / step height (0 when the line is above the first step, 1 when it is below the last) and calls setActive and render. This keeps jumps from the scrollbar, a dot or Replay correct. Also call sync() on load and after every resize, and from a second IntersectionObserver that watches the intro and the footer, so a jump from one end of the page to the other (which crosses no step) still resets or finishes the story. Still no scroll listener.
- onStepEnter also sets discrete state: current and completed node classes (three states, completed nodes stay dimmed), aria-current="step" on the active dot and an aria-live="polite" line reading "Step 3 of 4: The server renders".
- Measure each node's entry and exit edges once with offsetLeft / offsetTop and cache them; never read layout inside render. On window resize, orientationchange and document.fonts.ready call scroller.resize() and re-measure.
- Accessibility: steps have tabindex="0" with a visible focus outline, the dots are buttons that call scrollIntoView, and prefers-reduced-motion removes transitions and the bobbing cue and uses instant scrolling.
- Only add the class that dims inactive steps and nodes after confirming window.scrollama exists, so that a failed CDN leaves a fully visible article. Add a footer with the total time and a "Replay the story" button that scrolls to the top. Style it dark with a cyan accent.

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.

What's included

Features

Intersection Observer based
No scroll listener and no per-frame getBoundingClientRect.
Traveling packet
A dot travels along the wires from one node edge to the next, scrubbed by each step's progress value.
Live millisecond clock
A counter and a four-part waterfall show where the time goes as you read.
Wires that fill
Each connector is a gradient driven by one CSS custom property.
One pure render function
Enter, progress and jumps all draw the same state, so fast scrolling never leaves it half updated.
Phone-first sticky strip
Below 820px the graphic becomes a compact pinned strip and the steps scroll under it.
Keyboard and screen-reader path
Focusable steps, step dots with aria-current and an aria-live announcement.
Reduced-motion and no-CDN fallbacks
Transitions stop, dots jump, and without Scrollama the page stays a fully visible article.

About this UI Snippet

Scrollama Scrollytelling — A Sticky Graphic Scrubbed by Step and Progress Callbacks

Screenshot of the Scrollama Scrollytelling snippet rendered live

Scrollytelling, a graphic that stays put while text scrolls past and drives it, is the format behind most modern data journalism. The naive build is a scroll listener that calls getBoundingClientRect() on every step on every event, which fires hundreds of times a second and forces layout each time. Scrollama is a ~3kb wrapper around the Intersection Observer API: the browser watches the steps and reports threshold crossings off the main thread. This snippet goes beyond lighting up a node. A packet travels the pipeline as you read, the wires fill, the HTML streams into the server node and a millisecond clock counts up, all from the same two callbacks.

The layout does most of the work

The page is a two-column grid: .sss-sticky { position: sticky; top: 0; height: 100vh } beside a normally scrolling steps column. The explicit height: 100vh matters, because a sticky element with auto height sticks at its content height and drifts out of vertical alignment. Each step is min-height: 82vh, about a screenful per beat, and the steps column has padding: 30vh 0 so the first and last steps can sit centred. Below 820px the graphic becomes a compact strip pinned to the top with the four nodes in a row, and the steps scroll underneath it, so the reader never loses the diagram.

offset: where a step counts as entered

offset: 0.55 is a fraction of the viewport height measured from the top. At 0 a step triggers as it touches the very top, before the reader has begun reading it. At 1 it triggers at the bottom, before the text is legible. Just past halfway is the sweet spot: the reader is looking straight at the step as the graphic responds.

One render function, two callbacks

Everything is drawn by one pure function, render(index, progress). onStepProgress calls it continuously while you scroll steadily, which is cheap because it reads no layout. onStepEnter and onStepExit call a small sync() that works out the current step and its progress from where the steps are right now, measured against the same 55% line. That matters because a jump, such as dragging the scrollbar, clicking a dot or pressing Replay, can skip the progress events. With sync() the story always lands in the right state: back above the first step it resets to the start, past the last step it shows the finished story, and in between it picks up at the exact progress. One case Scrollama cannot see is a jump from one end of the page to the other, which crosses no step at all, so the intro and the footer are watched by a second IntersectionObserver that also calls sync(). The same call runs on load, so a page restored halfway down starts in the right place.

Scrubbing a packet, wires and a clock

The packet is a dot moved with a translate from the bottom edge of one node to the top edge of the next, along the wire (right edge to left edge on a phone), so it never covers a label, and a step's progress is how far along it is. Step 1 sends it from Browser to Edge, step 2 holds it at the Edge while the cache lookup turns into a miss, step 3 carries it to the Server while four chunks of HTML stream in, and step 4 takes it to the Client. Each wire is a CSS gradient with a hard stop at calc(var(--w) * 100%), so filling it is one custom property. The clock adds the milliseconds of the finished steps to the current step's share, and a four-part waterfall bar shows where the time goes. The timings are illustrative. Node edge positions are measured once and cached, so render() never reads layout while you scroll.

Three-state nodes with status chips

Nodes are upcoming, current or completed rather than on and off. Completed nodes stay at reduced opacity with a "done ✓" chip, so scrolling back shows a trail of where you have been, and the current node's chip names what it is doing: requesting, cache lookup, rendering, hydrating.

Navigation without a mouse wheel

Four dots jump to a step with scrollIntoView, the steps are focusable (tabindex="0") so Tab moves through the story and the browser scrolls each one into view, and a visually hidden aria-live region announces "Step 3 of 4: The server renders". The dot for the current step carries aria-current="step".

Resize, fonts and orientation

Scrollama measures every step during setup() and caches the offsets. A rotated phone, a resized window or a late web font all invalidate that cache, so the script calls scroller.resize() and re-measures the node edge positions on resize, orientation change and when fonts finish loading. Without it the steps fire at visibly wrong moments, a bug that rarely shows up in desktop testing.

Reduced motion and missing scripts

With prefers-reduced-motion the bobbing cue and transitions are switched off and the dots jump instead of scrolling smoothly. The scrubbing itself stays, because the reader drives it. If the CDN fails to load, the sss-js class is never added, so every step and node stays fully visible and the page reads as a normal article. Compare with scroll pin steps for a GSAP version, scroll progress if you only need a bar, or an onboarding tour for a guided alternative.

Step by step

How to Use

  1. 1
    Add the Scrollama CDNInclude scrollama from the CDN panel; it exposes a global scrollama() factory.
  2. 2
    Build a two-column gridA position: sticky graphic with height: 100vh beside a normal steps column, with min-height: 82vh per step.
  3. 3
    Set up ScrollamaPass step: ".sss-step", offset: 0.55 and progress: true, then chain onStepEnter and onStepProgress.
  4. 4
    Write one render(index, progress)Draw the packet, wires, clock and chips from those two numbers; call it from both callbacks.
  5. 5
    Sync on enter and exitWork out the current step and progress from the step positions, so jumps from a dot, the scrollbar or Replay always land correctly.
  6. 6
    Call resize() when the layout changesRe-measure on resize, orientation change and font load, and cache the node edge positions instead of reading layout while scrolling.
  7. 7
    Add the dots and focusable stepsButtons call scrollIntoView, tabindex="0" lets Tab walk the story and an aria-live line announces each step.

Real-world uses

Common Use Cases

Data journalism graphics
Explain a chart step by step with a sticky graphic that advances as each paragraph reaches the reader, without a costly scroll listener.
System and pipeline explainers
Walk a request, build or data pipeline through its stages, with a packet moving between nodes and the time spent in each stage added up.
Onboarding narratives
Offer a scrolled alternative to an onboarding tour, using discrete callbacks for state and progress callbacks for continuous motion.
Documentation walkthroughs
Highlight the matching part of an architecture diagram as each paragraph is read, with the trigger offset tuned so changes happen as the reader arrives.
Comparing scroll techniques
Put it beside scroll pin steps to compare a purpose-built library with GSAP pinning, or use scroll progress for just a bar.

Got questions?

Frequently Asked Questions

A scroll listener fires hundreds of times a second and usually calls getBoundingClientRect on every step, forcing layout each time. Scrollama wraps the Intersection Observer API, so the browser reports threshold crossings off the main thread and there is no throttling code to write.

Where in the viewport a step must reach before it counts as entered, as a fraction from the top. At 0 it fires before the reader has begun reading it, and at 1 before the text is legible. A value just past halfway, such as 0.55, fires as the reader looks at the step.

Both callbacks call it, so the packet, wires, clock and chips are always drawn from the same two numbers. A fast scroll that skips a progress event, or a jump from a dot, can then never leave the graphic half updated or out of step with the text.

Progress events only arrive while a step crosses the offset line, and a jump (the scrollbar, a dot, Replay) can skip them. Reading the step positions once on enter and exit gives the exact step and progress, so the story never stays half drawn or stuck on the last step after a jump to the top.

Scrollama measures each step during setup and caches the result, so a rotated phone, resized window or late web font leaves the triggers measured against a layout that no longer exists. The script also re-measures the node edge positions, because the packet moves between them.

Create the scroller in a mount effect after the steps have rendered, because setup measures the DOM, and call scroller.destroy() in the cleanup. Keep the active step in state for the classes and chips, but write the progress-driven transforms and CSS variables straight to refs, not through state.