GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

Scroll Path Animation: 9 Scenes Along a Curve

Scroll path animation lurches on bends. Fed straight into a curve, scroll progress makes 01's pen speed vary up to 6.2x; a 200-slice length table keeps it

Nine Scroll Scenes You Can Roll Inside the Stage

Auto-plays · click a tile to jump to its section · all nine in one zip

Leave a cell alone and it scrolls itself on a seeded back-and-forth schedule. One 5,400 ms loop is a 200 ms wait, 2,600 ms forward, a 500 ms hold at the end, 1,800 ms back and a 300 ms rest; the forward stretch is cut into four legs (five for 04 and 08), and each leg covers a different share in a different time, so the speed keeps changing. Turn the wheel over a cell, press it, or Tab into it, and the demo stops where it is and hands that progress to you.

The order starts with 01, which builds the length table. 02 pulls a tangent from that table, 03 pulls milestone distances, 04 pulls the normal and a speed reading, and 05 steps sideways into a 3D helix that needs no table at all. 06 hangs a tail from a head riding the table again, 07 measures the perimeter of two outlines instead of a curve, 08 shares one table across 160 dots, and 09 folds 01, 02 and 03 into a single delivery route.

Scroll Path Animation: 9 Effects — Even Line Draw, Tangent-Turning Plane, Milestone Pins, Speed Wobble, Helix Projection, Trailing Tail, Shape Morph, Flowing Dots and a Delivery Journey

All nine scenes are drawn on a 2D canvas, and the CSS holds zero @keyframes. Most scroll animation examples fade a card in as it enters the viewport; here a single number, progress, becomes a distance along a curve, and a point, a tangent and a normal are read at that distance. The time requestAnimationFrame hands over is cut into fixed 16 ms steps, up to 6 per frame, and a long gap between frames is only caught up to 100 ms.

A transparent scroll box laid over the canvas

Every cell is built the same way. The drawing belongs to a canvas marked aria-hidden="true", and a .sp__scroller box sits on top of it with position: absolute; inset: 0, invisible, catching the wheel, fingers and keys. Inside it an empty .sp__track is height: 600%, so scrolling five box heights takes progress from 0 to 1, and progress comes from the box's own scrollTop and nothing else.

    // Box progress = distance scrolled / scrollable distance. Reads only the stage box's scrollTop, not the page
    function maxScroll() { return Math.max(1, scroller.scrollHeight - scroller.clientHeight); }
    function readScroll() { return clamp(scroller.scrollTop / maxScroll(), 0, 1); }
    function placeScroll(p) {
        scroller.scrollTop = p * maxScroll();
        expectTop = scroller.scrollTop;
    }

In a 480×300 window the stage measured 418.6×261.6 px, so the scrollable distance was five times that height, 1,307 px. One 300 px wheel turn raised progress by about 23 percentage points while window.scrollY stayed at 0. During the demo the code scrolls the box to match, so the scrollbar moves along, and it writes the spot it set into expectTop so its own scrolling is not mistaken for a person's input.

Twenty-three frame comparisons over two seconds

The capture ran each scene at 480×300 for two seconds and grabbed 24 frames, which gives 23 frame-to-frame comparisons per scene.

Scene Cumulative area Frames that moved Mean intensity
01 Even line draw 18.6% 21/23 82.7
02 Tangent-turning plane 11.3% 23/23 112.1
03 Milestone pins 15.1% 23/23 97.0
04 Speed wobble 17.9% 23/23 80.3
05 Helix projection 47.7% 23/23 119.1
06 Trailing tail 22.1% 23/23 142.7
07 Shape morph 25.6% 23/23 106.7
08 Flowing dots 24.6% 23/23 127.8
09 Delivery journey 12.9% 23/23 120.4

05's 47.7% likely comes from the steps filling the middle of the stage while the camera follows the ball down, which pushes the whole staircase upward. 02 and 09 appear to stay at 11 to 13% because only one line plus a plane, or one parcel, changes the pixels it passes over. 01 alone counts 21/23, likely because the line waits until progress passes 6% and the 200 ms opening wait stacks on top, so the first few frames matched.

Where these scenes fit on a real page

Each scene was given one spot on a real page. 01 is the flow line running down a product intro page, 02 a decoration that leads the eye to a contact section, 03 a company history timeline and 04 a landing page section divider. 05 is a step-by-step app feature tour, 06 a brand mascot guide scene, 07 an icon change in a pricing comparison section and 08 a data flow explainer diagram.

09 imitates an order delivery tracking screen as a made-up scene: no place names or addresses, four stops labeled "Origin", "Stop 1", "Stop 2" and "Destination", and a map of 40 rectangular blocks whose sizes come from a seed. On a real tracking screen the progress slot would more likely take a value computed from the shipping stage rather than from scrolling, and counting the distance left as a share of length would carry over unchanged.

01A Line That Draws at an Even Pace Around Every Bend

The path runs through nine points spaced unevenly on purpose and draws between 6% and 94% progress, but progress does not go into the curve variable u directly: a binary search over a cumulative length table built from PATH_SAMPLES 200 slices finds the u for each length. Raised in 400 equal steps, progress moved the pen tip 3.047 to 3.267 px per step, within 1.07x, and 24 tick marks placed at equal lengths let you see that evenness, which suits a flow line on a product intro page where a line has to grow at a steady rate.

A Line That Draws at an Even Pace Around Every Bend
Open in a new tab

01 · A Line That Draws at an Even Pace Around Every Bend — Measured area 18.596% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

PATH_SAMPLESDRAW_STARTDRAW_END
    function buildLengthTable(curve, samples) {
        const us = [0];
        const lens = [0];
        let prev = curve(0);
        let acc = 0;
        for (let i = 1; i <= samples; i += 1) {
            const u = i / samples;
            const p = curve(u);
            acc += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
            us.push(u);
            lens.push(acc);
            prev = p;
        }
        return { curve: curve, us: us, lens: lens, total: acc };
    }
    // Length to u: binary-search the slice of the cumulative length table that holds s, then split that slice in proportion to length
    function uAtLength(tab, s) {
        const L = clamp(s, 0, tab.total);
        let lo = 0;
        let hi = tab.lens.length - 1;
        while (hi - lo > 1) {
            const mid = (lo + hi) >> 1;
            if (tab.lens[mid] < L) {
                lo = mid;
            }
            else {
                hi = mid;
            }
        }
        const span = tab.lens[hi] - tab.lens[lo];
        const k = span > 0 ? (L - tab.lens[lo]) / span : 0;
        return lerp(tab.us[lo], tab.us[hi], k);
    }

02A Paper Plane That Turns Along the Path's Tangent

The plane takes its tangent angle from the gap between its current point and one 18 px further along, folds the difference to its heading into -180° to 180°, and turns only TURN_SMOOTH 0.14 of it each tick. Scroll backward and the last-moved direction dir flips to -1, so it looks at the point behind instead; at that moment the target swings 177.6°, and the plane spends 640 ms on the half turn before it is back within 5°. A pink dashed line points at the target first and the plane chases it, a fit for a decoration that pulls attention toward a contact section.

A Paper Plane That Turns Along the Path's Tangent
Open in a new tab

02 · A Paper Plane That Turns Along the Path's Tangent — Measured area 11.272% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FOLLOW_LOOKAHEADTURN_SMOOTHPLANE_SIZE
    function aimAt(s) {
        const a = pointAtLength(TABLE, s);
        const b = pointAtLength(TABLE, s + dir * FOLLOW_LOOKAHEAD);
        const dx = b[0] - a[0];
        const dy = b[1] - a[1];
        if (Math.hypot(dx, dy) < 0.5) {
            return target;
        }
        return Math.atan2(dy, dx);
    }
    function step(dp) {
        if (dp > 1e-6) {
            dir = 1;
        }
        else if (dp < -1e-6) {
            dir = -1;
        }
        target = aimAt(travel());
        // Shortest direction: fold the angle gap into -180° to 180°, then turn only that fraction of it
        turn = wrapAngle(target - heading) * TURN_SMOOTH;
        heading = wrapAngle(heading + turn);
    }

03Milestones That Spring Up the Moment You Pass Them

The five milestones sit at evenly split u values of 0.1, 0.3, 0.5, 0.7 and 0.9, yet measured by length they land at 8.0, 27.1, 48.0, 69.3 and 90.3% of the 543.2 px path, so lengthAtU records each one's distance in advance and the flag's goal switches to 1 the moment progress passes it. With stiffness 0.12 and damping 0.8 the flag overshoots upright by 29.2° and settles in 544 ms, and scrolling back makes it rock the other way for the same 544 ms as it lies down, the kind of marker that stands up year by year on a company history timeline.

Milestones That Spring Up the Moment You Pass Them
Open in a new tab

03 · Milestones That Spring Up the Moment You Pass Them — Measured area 15.065% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

MILESTONE_COUNTPIN_STIFFNESSPIN_DAMPING
    function step(dp) {
        const s = travel();
        for (let i = 0; i < PINS.length; i += 1) {
            const pin = PINS[i];
            pin.up = s >= pin.s;
            const goal = pin.up ? 1 : 0;
            pin.v += (goal - pin.x) * PIN_STIFFNESS;
            pin.v *= PIN_DAMPING;
            pin.x += pin.v;
        }
    }

04A Line That Thickens and Wobbles the Faster You Scroll

Each tick the absolute change in progress passes through a VELOCITY_SMOOTH 0.08 low-pass filter, the effect reaches full strength at 0.008 progress per tick, and in proportion to it the line around the marker swells from 3 px to 16 px wide while a sine wave pushes it up to 14 px along the normal. Away from the marker both the wave and the width fade inside a 150 px bell curve, and once you stop, the filter falls below 8% in 464 ms and the wave below 1 px in 480 ms, so a landing page section divider holds the momentum of your scroll for a beat and then lets go.

A Line That Thickens and Wobbles the Faster You Scroll
Open in a new tab

04 · A Line That Thickens and Wobbles the Faster You Scroll — Measured area 17.865% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

VELOCITY_SMOOTHWOBBLE_AMP_MAXSTROKE_WIDTH_MAX
    function step(dp) { vel += (Math.abs(dp) - vel) * VELOCITY_SMOOTH; }
    // Wave at length s: sine offset along the normal times a bell-shaped falloff
    function waveAt(s, s0, amp) {
        const base = pointAtLength(TABLE, s);
        const n = normalAt(TABLE, s);
        const d = (s - s0) / WAVE_REACH;
        const env = Math.exp(-d * d);
        const off = Math.sin(s * WAVE_K - prog * WAVE_DRIFT) * amp * env;
        return [[base[0] + n[0] * off, base[1] + n[1] * off], env];
    }

05A Ball Spiraling Down a Helix in Perspective

One progress value sets both the helix angle (2.5 turns) and the height (300) in direct proportion, so this scene descends at an even pace with no length table, and each step point reaches the screen by subtracting the camera height, tilting 0.5 rad and scaling by 360 ÷ (360 + depth). That scale runs from 0.751 at the farthest step tip to 1.496 at the nearest, taking the ball from 21.5 px to 34.3 px across, while steps, pillar pieces, the ball shadow and the ball are sorted far to near and the farthest side is mixed 55% toward the background. It reads well as a step-by-step app feature tour where each stage of the descent stands for one feature.

A Ball Spiraling Down a Helix in Perspective
Open in a new tab

05 · A Ball Spiraling Down a Helix in Perspective — Measured area 47.66% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

HELIX_TURNSHELIX_RADIUSCAMERA_DISTANCE
    function project(x, y, z) {
        const yy = y - camY;
        const c = Math.cos(CAMERA_TILT);
        const sn = Math.sin(CAMERA_TILT);
        const y2 = yy * c - z * sn;
        const z2 = yy * sn + z * c;
        const k = CAMERA_DISTANCE / (CAMERA_DISTANCE + z2);
        return { x: CX + x * k, y: CY + y2 * k, z: z2, k: k };
    }

06A Tail That Drags Along the Path at a Fixed Link Length

The head is pinned to the progress distance on the path, while each of the 12 links moves again by 0.9 times what it moved last tick and is then pulled back to 15 px from its neighbor in 6 correction passes. Over one demo loop the link length error peaked at 18.3%, but with a single pass it climbed to 88.1% and the tail stretched like a rubber band, so for a brand mascot guide scene where the tail is part of the character, the extra passes are worth their cost.

A Tail That Drags Along the Path at a Fixed Link Length
Open in a new tab

06 · A Tail That Drags Along the Path at a Fixed Link Length — Measured area 22.145% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

TAIL_SEGMENTSSEGMENT_LENGTHCONSTRAINT_ITERATIONS
        // Move by the difference from the previous position: travel again what was traveled last tick, times the drag
        for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
            const vx = (xs[i] - pxs[i]) * TAIL_DRAG;
            const vy = (ys[i] - pys[i]) * TAIL_DRAG;
            pxs[i] = xs[i];
            pys[i] = ys[i];
            xs[i] += vx;
            ys[i] += vy;
        }
        // Link distance correction: the head (0) stays on the path, link 1 moves the full amount alone, other neighbor pairs move half each
        for (let it = 0; it < CONSTRAINT_ITERATIONS; it += 1) {
            for (let i = 1; i <= TAIL_SEGMENTS; i += 1) {
                const dx = xs[i] - xs[i - 1];
                const dy = ys[i] - ys[i - 1];
                const d = Math.hypot(dx, dy) || 1e-6;
                const diff = (d - SEGMENT_LENGTH) / d;
                if (i === 1) {
                    xs[i] -= dx * diff;
                    ys[i] -= dy * diff;
                }
                else {
                    xs[i] -= dx * diff * 0.5;
                    ys[i] -= dy * diff * 0.5;
                    xs[i - 1] += dx * diff * 0.5;
                    ys[i - 1] += dy * diff * 0.5;
                }
            }
        }

07A Line That Changes Shape by Resampling at Equal Lengths

The star's 10 points and the heart's 40 points are spaced unevenly (2.07 to 20.71 px apart on the heart), so both outlines are resampled to 96 points at equal perimeter spacing, and out of 96 possible rotations the 48-slot shift with the smallest sum of squared distances lines up their start points. Without that alignment each point crosses an average of 137.6 px through the shape, against 34.4 px with the shift, and between progress 0.25 and 0.75 the shape stays pinned at the center of the stage while it blends, which suits an icon change in a pricing comparison section.

A Line That Changes Shape by Resampling at Equal Lengths
Open in a new tab

07 · A Line That Changes Shape by Resampling at Equal Lengths — Measured area 25.627% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

MORPH_POINTSPIN_RANGE_STARTPIN_RANGE_END
    // Start-point alignment: rotate b by k slots and keep the k with the smallest sum of squared distances
    function bestShift(a, b) {
        const n = a.length;
        let best = 0;
        let bestCost = Infinity;
        for (let k = 0; k < n; k += 1) {
            let cost = 0;
            for (let i = 0; i < n; i += 1) {
                const q = b[(i + k) % n];
                cost += (a[i][0] - q[0]) * (a[i][0] - q[0]) + (a[i][1] - q[1]) * (a[i][1] - q[1]);
            }
            if (cost < bestCost) {
                bestCost = cost;
                best = k;
            }
        }
        return best;
    }

08A Swarm of Dots That Flows Along the Path With Scroll Speed

Each of the 160 dots keeps its own distance along the path and its own sideways offset, and every tick it moves the base flow of 0.5 px plus a gain of 2.4 times the scrolled distance, smoothed at a 0.2 rate. The sideways offset wobbles within ±40 px through a seeded 256-cell value noise so the swarm reads like a current, and during the demo the largest single-tick advance reached 20.01 px, 40 times the base, before dropping to −18.71 px as it flowed backward on the return, which makes the direction visible on a data flow explainer diagram.

A Swarm of Dots That Flows Along the Path With Scroll Speed
Open in a new tab

08 · A Swarm of Dots That Flows Along the Path With Scroll Speed — Measured area 24.613% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

FLOW_DOTSFLOW_SPEED_GAINFLOW_SPREAD
    function step(dp) {
        flow += (dp * TABLE.total - flow) * FLOW_SMOOTH;
        age += 1;
        const adv = advance();
        for (let i = 0; i < dots.length; i += 1) {
            const d = dots[i];
            d.s = ((d.s + adv * d.pace) % TABLE.total + TABLE.total) % TABLE.total;
        }
    }
    function sideOf(d, s) {
        return clamp(d.lane * FLOW_SPREAD * 0.75 + noise1(s * JITTER_SCALE + age * JITTER_TIME + d.seed) * FLOW_SPREAD * JITTER_AMP, -FLOW_SPREAD, FLOW_SPREAD);
    }

09A Delivery Tracking Journey That Counts the Distance Left

This scene ties 01's line draw, 02's tangent chase and 03's spring pins onto one 695.1 px route. The parcel does not rotate to face the tangent; it only tilts toward the inside of each bend by 8 times this tick's turn of the damped tangent angle, and the tilt stops at ±0.35 rad (20.1°). The distance left is 12.6 km × (1 − distance traveled ÷ total length), so the four stops placed at even u values pass at uneven readings of 12.6, 8.66, 4.10 and 0 km, while the status line moves from "ready to depart" through stop names followed by "passed", such as "Origin passed" and "Stop 1 passed", to "arrived", the arc of an order delivery tracking screen.

A Delivery Tracking Journey That Counts the Distance Left
Open in a new tab

09 · A Delivery Tracking Journey That Counts the Distance Left — Measured area 12.894% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

ROUTE_TOTAL_KMSTOP_COUNTBOX_TILT_MAX
    function step(dp) {
        if (dp > 1e-6) {
            dir = 1;
        }
        else if (dp < -1e-6) {
            dir = -1;
        }
        // 02: chase the tangent angle with damping along the shortest direction, and tilt the box toward the inside of the bend by this tick's turn
        target = aimAt(travel());
        turn = wrapAngle(target - heading) * ROUTE_TURN_SMOOTH;
        heading = wrapAngle(heading + turn);
        tilt += (clamp(turn * TILT_GAIN, -BOX_TILT_MAX, BOX_TILT_MAX) - tilt) * 0.2;
        // 03: once a stop's length is passed its pin springs up, and scrolling back presses it down again
        const s = travel();
        for (let i = 0; i < STOPS.length; i += 1) {
            const st = STOPS[i];
            st.up = s >= st.s;
            st.v += ((st.up ? 1 : 0) - st.x) * STOP_STIFFNESS;
            st.v *= STOP_DAMPING;
            st.x += st.v;
        }
    }

Why Does Feeding Scroll Progress Straight Into a Curve Speed Up and Slow Down at the Bends?

Every slice of the curve gets the same share of u

The paths here are cubic curves that makeCurve builds through a list of points. The shape of each slice between two points comes from its four neighbors, and the curve variable u hands every slice the same share no matter how long that slice is. On a long slice a small change in u carries the pen tip far, and on a short or sharply bending one the same change covers only a few pixels, so steady progress turns into uneven speed on screen.

Pen tip speed measured over 400 steps

On 01's path, progress was raised in 400 equal steps, and the distance the pen tip covered per step was compared against a true length measured with 4,000 slices. The full path is 1,261.8 px, and the 200-slice table records 1,261.1 px.

Mapping from progress to position Min per step Max per step Max ÷ min Coefficient of variation
Raw u 0.842 px 5.191 px 6.16x 36.49%
24-slice length table 1.252 px 5.365 px 4.29x 25.24%
200-slice length table 3.047 px 3.267 px 1.07x 1.12%

Even with raw u, the values 0.25, 0.5 and 0.75 land at 23.3%, 47.7% and 76.6% of the path, so the overall picture looks close. The trouble is the individual steps in between, which spread more than sixfold, and the eye catches that jolt well before it registers any average. A typical path animation example in SVG sidesteps the problem by measuring length with getTotalLength and getPointAtLength; on a canvas, the table in the next section does that job.

Slicing the Curve Into a Cumulative Length Table, Then Turning Progress Into Distance, Point and Tangent

Build the table once, binary-search it every tick

buildLengthTable cuts u into 200 slices and adds up the straight-line distance to the end of each one. That happens once when the scene starts; after that, the only per-tick work is uAtLength finding, by binary search, the slice that contains a length s. The table has 201 entries, so the search finishes within 8 comparisons, and inside the slice u is split in proportion to length. 01 clamps progress between DRAW_START and DRAW_END, stretches it back to 0 to 1 and multiplies by the total length to get the length to draw, and tracePath then follows u in fine steps, 600 for the full path, so the drawn line stays smooth whatever the table's slice count.

Pulling the tangent and the normal from the same table

With the table, the point at any length s is one lookup away, so direction is measured by length too. 02 and 09 feed the difference between the points at s and s + 18 px (16 px for 09) into atan2 for the tangent angle, and at the end of the path, where the two points come within 0.5 px, they keep the previous angle. 04's normalAt turns the difference between points 1 px either side of s by 90° to get the normal, and the wave pushes along it. 03 and 09 go the other way with lengthAtU, measuring in advance how many pixels along the path each milestone and stop sits.

The two scenes that skip the table

05's helix has its angle and height both in direct proportion to progress, so there is no length to correct. 07 works on polygon outlines rather than a curve, so resample adds up edge lengths directly to get the perimeter and drops new points at equal spacing. The other seven, 01 to 04, 06, 08 and 09, share the same table building and lookup.

Three Variations: Fewer Slices, a Bigger Wobble, a Longer Tail

Name Value Feel
Use fewer length-table slices 01 PATH_SAMPLES 200 → 24 At 200, raising progress in equal steps moves the pen tip 3.05 to 3.27 px per step, within 1.07x; at 24 each table slice is longer, the step spreads to 1.25 to 5.37 px (4.3x), and on tight bends the line briefly races ahead and then hesitates
Raise the wobble amplitude 04 WOBBLE_AMP_MAX 14 → 36 At 14 the wave around the marker swings 14 px along the normal at top speed; at 36 it swings 36 px, and although the speed filter calms in the same 464 ms after you stop, the wave takes 656 ms instead of 480 ms to drop below 1 px
Add more tail links 06 TAIL_SEGMENTS 12 → 30 At 12 a 180 px tail follows the head and the link length error peaks at 18.3% in the demo; at 30 the tail grows to 450 px, its end strays up to 447.5 px from the path as it cuts across the inside of the bends, and the same 6 passes leave up to 36.4% link error

Each value trades one thing for another. Fewer slices save work building the table and give up even speed, and a bigger wobble shows the force of a scroll more boldly but takes longer to calm after you stop. A longer tail drifts further from the path, so when raising the link count to something like 30, raise CONSTRAINT_ITERATIONS with it and measure the error again.

Sharp Corners, Scrolling Backward, One Huge Wheel Jump — Where Path Following Breaks

Too few slices on a tight bend

Dropping to a 24-slice table spreads pen speed to 4.29x and also makes the table's total 1,242.9 px, 18.9 px short of the true length. The straight chord between slice ends cuts across the curve's belly, and the tighter the bend, the less u and length stay proportional inside one slice, so the speed wobbles again within it.

The tangent flips the moment you scroll back

In 02 and 09, the point ahead jumps behind the moment direction reverses, and the tangent target turns 177.6°. With TURN_SMOOTH at 1 the plane spins the half turn in a single 16 ms tick, while 0.14 spreads it over 640 ms. Shortening FOLLOW_LOOKAHEAD to 4 px stretched the return to within 5° out to 1,680 ms, so looking closer ahead does not make the heading correct itself faster.

When one wheel turn moves progress a long way

When progress lands in one big jump, as with the End key, 04's speed level goes straight to the cap and the status line reads "scrolling fast". 06's head jumps ahead in a single tick, the 6 correction passes cannot keep up, and the links arrive stretched. In 03, passing several milestones at once sets each flag rocking on its own, and raising damping from 0.8 to 0.95 pushes the overshoot from 29.2° to 71.7° and the settle time from 544 ms to 2,368 ms.

The zip has every scene as a standalone index.html with the length table, tangent chase and link correction intact, plus nine React ports that run the same math, and it opens with the password tmvtd6sv.

A wheel over the stage stays in the stage

The box carries overscroll-behavior: contain, so while the pointer is over the stage the wheel scrolls only the box, and reaching its end does not pass the leftover scroll to the page. No code intercepts the wheel, but a wheel over the stage belongs to the box, and a reader who scrolls there mid-article may feel the page has stalled. To keep scrolling the page they have to move the pointer off the stage, so on a real page it seems safer to keep the stage to a fraction of the screen height. Progress is also tied to box height: the scrollable distance is 5 × the box height, so on a smaller stage, such as at 320 px wide, one wheel notch moves progress further, and on resize placeScroll scrolls the box back to the same progress.

Points pressed up against the camera

05 scales by 360 ÷ (360 + depth), so as depth approaches −360 the divisor nears 0 and a step balloons. In the current layout the nearest depth is −119, which caps the scale at 1.496. Steps are sorted by one average depth each, so where steps overlap over a long stretch, front and back would likely swap, though with steps that do not pass through each other, as here, no misordering was visible.

Leaving the Page Scroll Alone — a Stage-Only Scroll Box and Reduced-Motion Accessibility

In all nine scenes the only listener attached to window is resize; the scroll and wheel listeners go on the box inside the stage. The wheel listener is { passive: true } and only stops the demo, with no preventDefault, and no position: sticky stretch pins the page. Hijacking the wheel to turn vertical scrolling into horizontal travel is the route the horizontal scroll scenes took; this post goes the opposite way.

Keyboard progress and the status line

The box has tabindex="0", role="region" and an aria-label describing the scene and how to operate it. With focus on it, ↓ and → move progress 4% forward, ↑ and ← move it 4% back, PageDown and PageUp move it 25% forward or back, Home goes to the start and End to the finish; preventDefault covers only those keys, so Space and the rest behave normally inside the box. The status line stays at aria-live="off" during the demo, switches to polite on the first input, and updates only in 5% steps, such as "Progress 30%", so scrolling does not flood a screen reader. Listeners are attached through one AbortController and removed together in the cleanup function, which is also how the React ports unmount.

What reduced motion leaves on screen

With prefers-reduced-motion: reduce on, the demo schedule and animation loop do not run; instead settle() computes 240 steps in one go without drawing them and leaves a single settled frame. 01 to 06, 08 and 09 open at the end of progress (1), so 01 starts on "line complete" and 09 on a distance left of 0.0 km and "arrived". 07 alone stops at 0.75, the end of its pinned range, because at 1 the shape has slid out of the stage and only an empty stage would remain. Scrolling the box or pressing a key still runs the same calculation through after() and draws the settled frame for that progress at once, and a change listener on the media query picks up a setting changed while the page is open.

SVG Path Scroll Animation in Practice: Common Questions

Can a scroll line animation css setup draw a line along a curve without JavaScript?

Drawing the line itself, yes. The route map item in the scroll storytelling sections post draws it with a CSS scroll timeline and an SVG path, and the SVG side handles the length. Movement that has to remember the previous tick, such as 02's tangent chase, 03's spring settle or 06's link correction, cannot be derived from progress alone and needs a script.

How do I tie these scenes to the page's own scroll instead of the box?

Progress is read in one place, readScroll, so swapping the box's scrollTop for the share of a page section that has scrolled past would likely leave the rest of the math untouched. In that case remove the overlaid scroll box and its overscroll-behavior: contain as well, or the wheel stays trapped. If all you need is a page depth number or badge, the scroll position indicators are lighter.

Does a fast wheel flick make the math fall behind?

Each frame runs at most 6 steps, and a long frame gap is only caught up to 100 ms, so returning to a tab left idle does not trigger hundreds of steps in a burst. The length table is built once when the scene starts, each tick only runs a binary search, and the canvas pixel ratio is capped at 2 with Math.min(window.devicePixelRatio || 1, 2).

Enter the archive password

The password is inside this article. You will find it as you read.