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
rootis null, or a scrolling element you pass. - rootMargin
- Grows or shrinks the watched region. Negative values shrink it:
-45% 0px -45% 0pxleaves 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.
.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.
Try it yourself
- 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 reads0, 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
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 printschart still at the top of the scroller: false. This is the second layout of the next example.
Code & diagrams
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
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: trueA 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
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: falseTwo 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
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)// 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.
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
scrollposition has already moved when your code runs. The first call afterobserve()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. - ▸
stickysticks relative to its nearest ancestor withoverflowother thanvisible, even if that ancestor never scrolls, so the figure never moves relative to it. That is whyoverflow: hiddenon a wrapper breaks it.overflow: clipdoes not create a scroll container and does not break sticky, wherehiddendoes. - ▸
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: truedoes 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
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
**
position: stickyholds 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 stickyfigure. The second example measures the three ways sticky quietly stops working: an ancestor withoverflow: hidden(orauto), a parent only as tall as the figure, and a sticky element that is stretched to the parent's height (usealign-self: startin a grid or flex layout). - 3
**
IntersectionObserverfinds 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
**The
rootis the scroller.** Withroot: nullthe observer watches the browser window (a real page). Withroot: scrollerit 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
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
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
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
How does position: sticky keep a chart on screen, and what three things stop it from working?
How does an IntersectionObserver band across the middle of the screen find the active step?
Why does threshold: 0.5 fail for tall steps?
Why can the active step change when nobody scrolls, and how must your code deal with it?
Practice
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.
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.