Command Palette

Search for a command to run...

Hectal
PHASE 17Advanced ~28 min· topic 3 of 6

Topic 17.3

Scrollytelling with position: sticky and IntersectionObserver

In one line

Scrollytelling keeps one chart fixed on screen while text steps scroll past it, and each step changes the chart. Two browser features do the whole job: position: sticky holds the chart, and IntersectionObserver tells you which step is in the middle of the screen.

Think of it like this

A lecture with a slide projector. The slide stays on the wall while the speaker talks, and each time the speaker reaches a new point, the slide changes. Scrollytelling is the same: the chart is the slide, the scrolling text is the speaker, and the reader's scroll is the clicker.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

scrollytelling
A page where scrolling text steps change a chart that stays on screen.
sticky
position: sticky: an element that scrolls normally until it reaches an offset, then stays there while its parent is still visible.
IntersectionObserver
A browser object that calls your function when watched elements enter or leave a region of the screen.
root
The box the observer watches inside: the window when root is null, or a scrolling element you pass.
rootMargin
Grows or shrinks the watched region. Negative values shrink it: -45% 0px -45% 0px leaves a thin band in the middle.
threshold
How much of an element must show before the observer calls you, from 0 to 1.
step
One block of story text. When it is in the middle of the screen, it is the active step.

Step by step

01Two columns, one sticky

The first example has a scrolling box with a layout of two columns. The left column holds four steps, each at least 240 px tall. The right column is a figure with position: sticky; top: 0; align-self: start.

Without align-self: start, the figure would stretch to the full height of the grid row and have nowhere to stick.

the sticky chartwhole filecss
.figure { position: sticky; top: 0; align-self: start; }

02Watch a band, not the steps' edges

The observer's rootMargin of "-45% 0px -45% 0px" turns the scroller into a band 10 % tall in the middle. When a step overlaps the band, the callback reports it, and the code colours that step's bar.

The code keeps one variable, active, and ignores a report for the step that is already active.

03Scroll in code, wait for the observer

The example sets scroller.scrollTop and waits for the observer's callback to report the step it wanted. Callbacks arrive on the browser's next frame, never inside your assignment, so the code awaits a promise that the callback resolves.

The log shows the order: step 0 at load (the observer reports the step already in the band), then 2, 3 and 1. Jumping from 0 to 2 skips step 1: an instant jump does not pass through the middle steps.

04Break sticky three ways

The second example puts three identical figures in three layouts and scrolls each by 300 px. Only one stays at the top. The other two scroll away, and the page prints no error: the figure is simply not sticky.

05Tall steps and a resized window

The third example is about two real surprises. A threshold fires for none of three tall steps; the band fires for all three. And the same scroll position reports a different step once the scroller gets taller.

Tall steps and a resized windowdiagram
Rendering diagram…

Try it yourself

  1. 1

    Make the band thicker

    In the first example, change "-45% 0px -45% 0px" to "-20% 0px -20% 0px". The band is now 60 % of the scroller. The log reads 0, 1, 2, 3, 0, 1: step 1 is reported on the way to step 2, and step 2 on the way to 3, because each overlaps the thick band while the reader passes. A thick band reports steps the reader only passes, and can hold two steps at once. Topic 17.4 picks the step closest to the middle.

  2. 2

    Un-stick the chart on purpose

    In the first example's CSS, add overflow: hidden; to .layout. The chart scrolls away with the text and the last log line prints chart still at the top of the scroller: false. This is the second layout of the next example.

Code & diagrams

A sticky chart and an observer that finds the active step New tab

The code scrolls its own box; a reader would use the mouse or a finger. Step 1 is skipped on the way from 0 to 2, because an instant jump does not pass through it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

active step: 0 Winter
active step: 2 Summer
active step: 3 Autumn
active step: 1 Spring
chart still at the top of the scroller: true
Three layouts: only one keeps its figure New tab

A failure that prints no error: the CSS is valid in all three, and two of them are simply not sticky. Top 0 means the figure stayed at the top edge of the scroller.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

works                                  | scrolled 300 px | figure top: 0 | stuck: true
ancestor has overflow: hidden          | scrolled 300 px | figure top: -300 | stuck: false
parent is only as tall as the figure   | scrolled 300 px | figure top: -290 | stuck: false
Tall steps and a resized scroller New tab

Two failures with no error: a threshold that never fires for tall steps, and an active step that changes when the window is resized. The sweep scrolls each step by 140 px past its top, so the middle of the scroller is inside it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

threshold 0.5, tall steps   -> steps reported: none
middle band, tall steps     -> steps reported: 0, 1, 2
scroller 220 px tall, active step: 0
scroller 320 px tall, active step: 1 (nobody scrolled)
The same idea with Scrollama (not live: the library is not in the preview)js
// From the Scrollama README (MIT). It wraps the same IntersectionObserver technique.
import scrollama from "scrollama";

const scroller = scrollama();

scroller
  .setup({
    step: ".step",   // selector or array of elements
    offset: 0.5,     // 0 to 1, or "300px": how far from the top a step triggers (default 0.5)
  })
  .onStepEnter((response) => {
    // response = { element, index, direction }   direction is "up" or "down"
    setChartState(response.index);
  })
  .onStepExit((response) => {
    // { element, index, direction }
  });

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Use a threshold on tall steps

In the first example, replace the observer options with { root: scroller, threshold: 0.5 }, and make each .step min-height: 600px in the CSS.

terminal
$ Console
── what you'll see ──
(nothing is printed: no step is ever reported)

Myth vs fact

Myth

Scrollytelling needs a library like Scrollama or GSAP.

Fact

Two browser features do it: position: sticky and IntersectionObserver. Libraries add convenience (progress callbacks, pinning), not capability.

Myth

Listening to the scroll event is the normal way.

Fact

A scroll handler runs on every scroll tick and tempts you to read layout inside it, which is slow. IntersectionObserver is called by the browser only when something crosses the band.

Myth

threshold: 0.5 means the step is in the middle of the screen.

Fact

It means half of the step's own height is visible. For a step taller than the screen, that is never true. A band in the middle, made with rootMargin, is what you want.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Observer callbacks are batched and delivered in a task after the browser has laid out the frame, so the scroll position has already moved when your code runs. The first call after observe() always reports the current state of every watched element, which is why step 0 prints at load. That initial call is also how a page opened half-way down (a reloaded tab) finds its active step.

  • ▸

    sticky sticks relative to its nearest ancestor with overflow other than visible, even if that ancestor never scrolls, so the figure never moves relative to it. That is why overflow: hidden on a wrapper breaks it. overflow: clip does not create a scroll container and does not break sticky, where hidden does.

  • ▸

    For smooth chart motion that follows the scroll (a line that draws as you scroll), you need progress inside a step, not just which step is active. Scrollama's progress: true does it with a series of thresholds. Without a library, an observer with many thresholds (Array.from({ length: 101 }, (_, i) => i / 100)) gives a 0 to 1 ratio for steps that fit on screen.

Remember this

  1. 1

    The problem this solves: the story in Topic 17.2 has four moves, and a reader should see each move on the chart at the moment they read about it. If the chart scrolls away with the text, the reader sees words with no picture. If the chart is far above, the reader scrolls back and forth. The chart has to stay, and it has to change when the words change.

  2. 2

    **position: sticky holds the chart.** A sticky element scrolls like normal until it reaches the offset you gave (top: 0), then stays there while its parent is still on screen. It leaves with the parent. So the layout is a parent with two children: a column of steps and a sticky figure. The second example measures the three ways sticky quietly stops working: an ancestor with overflow: hidden (or auto), a parent only as tall as the figure, and a sticky element that is stretched to the parent's height (use align-self: start in a grid or flex layout).

  3. 3

    **IntersectionObserver finds the active step.** You give it a list of elements and a callback. The browser calls the callback when an element enters or leaves a region, with no scroll listener and no layout reads in your code. For a story, the region is a thin band across the middle of the screen: rootMargin: "-45% 0px -45% 0px" shrinks the root by 45 % at the top and at the bottom. The step that overlaps the band is the active one. The first example scrolls its own container to each step, in code, and prints which step the observer reports.

  4. 4

    **The root is the scroller.** With root: null the observer watches the browser window (a real page). With root: scroller it watches a scrolling box inside the page, which is how the examples here work. The thresholds and margins mean the same thing in both. In your own page, you will use the window.

  5. 5

    Why a band and not a threshold. The obvious setting, threshold: 0.5 ("fire when half the step is visible"), fails for steps taller than the screen: a 500 px step in a 220 px scroller can never be more than 44 % visible, so the callback never fires. The third example sweeps three tall steps with both settings: the threshold reports none, the band reports all three. A band also behaves the same whatever the step height.

  6. 6

    Resizing changes the active step. The band is a percentage of the scroller, so when the window gets taller (a phone turned sideways, a resized window) the band moves, and the step under it can change with no scrolling. The observer reports it by itself, because it recomputes on any layout change. The third example prints this: the active step goes from 0 to 1 when the scroller grows from 220 to 320 px. Your code must treat each report as the truth, never as "the next step".

  7. 7

    No library needed, and libraries exist. Scrollama wraps this exact technique: scrollama().setup({ step: ".step", offset: 0.5 }).onStepEnter(({ element, index, direction }) => { … }), with progress callbacks and a built-in resize observer. It is a good choice if you need per-step scroll progress. GSAP's ScrollTrigger pins and scrubs animations; plain CSS scroll-driven animations (animation-timeline: scroll()) are newer and are not in every browser. For a few steps that each set a state, thirty lines of your own code are enough, and you control accessibility and fallbacks (Topic 17.5).

Explain it without notes

01

How does position: sticky keep a chart on screen, and what three things stop it from working?

02

How does an IntersectionObserver band across the middle of the screen find the active step?

03

Why does threshold: 0.5 fail for tall steps?

04

Why can the active step change when nobody scrolls, and how must your code deal with it?

Practice

01

Write activeIndex(tops, bottoms, mid) that returns the index of the step whose box contains mid (the y position of the band's centre), or -1 when none does. Test it with a gap between steps, with the midpoint exactly on a boundary, and with no steps.

02

Change the first example so the active step is also written as the figure's aria-label, for example Average rides per day by season, Summer highlighted. Say what you would also do for readers who cannot see the highlight.

Trade-offs

  • ↔

    Your own thirty lines give you full control of accessibility, fallbacks and bundle size. A library gives progress callbacks, a tested resize path and less to maintain.

  • ↔

    A thin band reports one step at a time and feels precise. A thick band tolerates short steps but needs a rule for choosing among several steps in it.

Done when you can

  • I can build a layout with a sticky figure and say what makes sticky fail.

  • I can use an IntersectionObserver with rootMargin to find the active step.

  • I can explain why a threshold fails for steps taller than the screen.

  • I can say why the active step may change on resize and handle it.

  • I can say when I would reach for Scrollama instead of my own code.