Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 12.6Intermediate ~19 min · shot 6 of 7

Sticky Storytelling and Smooth Scrolling

In one line

Scrollytelling pins a stage with position: sticky while steps scroll past it, and an observer says which step is current. Smooth-scroll libraries such as Lenis replace the browser's scrolling with their own, which is why they are controversial.

0/7 · 0%

Think of it like this

A museum guide with a lantern. The painting stays on the wall (the stage), and as you walk past the labels one by one, the guide changes what the lantern lights up. (Data Viz Topic 17.3 built this with a chart as the stage; here is the animation side.)

Words you'll meet

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

scrollytelling
A page where a stage stays in place and changes as steps of text scroll past.
position: sticky
An element that scrolls with the page until it reaches an offset (like top: 0), then stays there while its parent is on screen.
smooth scrolling library
A script (such as Lenis) that moves the scroll position in eased steps instead of leaving it to the browser.
scroll-jacking
Taking control of scrolling away from the user: blocking the wheel, locking the page, or changing its speed so it no longer feels native.

Step by step

01Find the current step

In "A stage that changes with the steps" the stage says Battery, Motor, Frame as each step crosses the middle.

02Check the keyboard

Click the box and press Down or Space: it scrolls, because it is a normal scroller with focus.

03Run Lenis

"Smooth scrolling with Lenis" scrolls to 200 px in 400 ms. With Reduced motion on, it jumps at once.

04Clean up

destroy() removes the lenis class. Call it when a component goes away.

Try it yourself

  1. 1

    Move the line

    In the first example change the observer's rootMargin to "0px 0px -90% 0px". The measured result is step 2: the stage says Battery and step 3: the stage says Motor: the stage is one step behind, because the line now sits near the top, and the step you have scrolled to has not reached it.

Code & diagrams

A stage that changes with the steps

A sticky stage in a 140 px scroller, and an observer with a line across the middle. The script scrolls for you.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

at load the stage says: Battery
step 1: the stage says Battery
step 2: the stage says Motor
step 3: the stage says Frame
the scroller can be focused and scrolled with the keyboard: true
Smooth scrolling with Lenis

Lenis on a box. Run it with the timeline's Reduced motion switch on: the second line becomes false, because the scroll is instant.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

Lenis 1.3.27 · class on the wrapper: scroller lenis
part way after 150 ms of a 400 ms scroll: true
arrived: scrollTop 200, still scrolling: false
after destroy() the class is gone: scroller

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

Give the stage a parent that is only as tall as the stage

In "A stage that changes with the steps" wrap the stage in a div: <div><div class="stage" id="stage">Battery</div></div>.

terminal
$ Scroll the box 200 px and read the stage's top edge
── what you'll see ──
The stage scrolls away: its top is at -199 px relative to the box (measured). The computed `position` is still `sticky`.

Myth vs fact

Myth

Smooth scrolling libraries are needed for scroll animations.

Fact

Scroll-driven animations work on native scrolling. A library only changes how the scroll position moves. Most sites in this phase need none.

Pro corner

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

  • ▸

    A smoothed scroll position lags the input, so scroll-driven animations look a little late. Lenis leaves touch native by default (syncTouch is false).

Remember this

  1. 1

    The problem this solves: a long explanation (battery, motor, frame) is easier when one picture stays on screen while the words change. You need a way to hold the stage in place and a way to know which step the reader is on.

  2. 2

    **position: sticky holds the stage.** A sticky element scrolls normally until it reaches its offset (top: 0), then stays, but only while its parent is still on screen. In the first example the stage sticks to the top of the box (measured: after 200 px of scrolling it is at the box's top edge). Wrap it in a parent only as tall as itself and it scrolls away (measured: -199 px).

  3. 3

    An observer picks the step. An IntersectionObserver with rootMargin: "-50% 0px -50% 0px" shrinks the observed area to a line across the middle of the scroller. The step crossing it sets the stage's text (measured: Battery, Motor, Frame). A bottom margin of -90% puts the line near the top, and the stage changes one step later.

  4. 4

    Make it work for everyone. Every step is real text in reading order, so a screen reader reads the story without the stage. Keep aria-live off on the stage. A scroller that is not the page needs tabindex="0" and a name (role="region", aria-label) so keyboard users can focus and scroll it (the first example prints true). Find-in-page and #anchor links work because the scrolling is native.

  5. 5

    Smooth-scroll libraries. Lenis (1.3.27 here) keeps the real scrollTop but moves it in eased steps on its own requestAnimationFrame loop, so scroll-driven animations and observers still follow it. The second example adds the class lenis to a box, scrollTo(200) takes 400 ms (part-way after 150 ms), and destroy() removes the class.

  6. 6

    Lenis needs its loop, and respects reduced motion. With autoRaf: true it runs its own loop; without it you must call lenis.raf(time), or nothing moves (measured: scrollTo(200) leaves scrollTop at 0, isScrolling stuck at "smooth"). In 1.3.27 respectReducedMotion is on by default: with the Reduced motion switch the same scrollTo jumps at once (measured).

  7. 7

    The scroll-jacking debate. Against: smoothing changes a speed people have learned, can be uncomfortable with vestibular conditions, and runs JavaScript every frame (battery). For: a consistent feel and an eased scrollTo for anchors. Rules: never block the wheel or lock the page, keep touch native (Lenis does by default), turn smoothing off for reduced motion and the site toggle.

Explain it without notes

01

How does the first example decide which step is current, and why does it still work with a keyboard and find-in-page?

02

Give two reasons to be careful with a smooth-scroll library, and one way to make it safer.

Practice

01

Write the CSS and the three HTML parts of a sticky stage with two steps, so that the stage sticks to the top of the page.

02

Make Lenis run only when motion is allowed, and stop when the visitor switches motion off.

Done when you can

  • You can build a sticky stage and say why its parent must be tall

  • You pick the current step with an observer and keep native scrolling for the keyboard

  • You know what Lenis does, and that it needs autoRaf and destroy()

  • You can argue the scroll-jacking trade-off and switch smoothing off