Scroll✨ Premium

Path Drawing on Scroll

A 364-unit SVG curve that draws itself on scroll — stroke-dashoffset 600 → 0 on a named timeline, 1,200 px of travel — and four milestones that grow (r 0 → 5 px), each on its own animation-range: 0-15%, 25-40%, 50-65%, 75-90%. CSS only, zero JavaScript.

CSSSVGscroll()

Updated

This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Scroll has 70 effects, including 12 free. Explore the category →

3 usage examples

A parcel's route across four platforms — delivery tracking page — Path Drawing on Scroll example 1

① A parcel's route across four platforms — delivery tracking page

WhenOrder tracking page: the line draws itself from the warehouse to the front door as the customer scrolls down, and each platform (departure, regional hub, depot, delivery) lights up in turn under the text of its step.
WhyEvery milestone has its own scroll range: the platform appears exactly when its paragraph arrives, without a line of script — fx-0582 Timeline Scroll lines cards up on a straight axis, not a curved route with dated points.
SettingsSold engine untouched (--pathScroll timeline, dash 600 → 0, ranges 0-15 / 25-40 / 50-65 / 75-90%). Container under a 48 px header (312 px), a 1,200 px stage laid out as a grid — 230 px sticky map, steps column — i.e. 888 px of travel; own 302-unit path with pathLength="600" so the line finishes at 100%; milestones placed on the curve with getPointAtLength at 0, 37, 63 and 100% of its length; userSpaceOnUse blue → green gradient; own keyframes r 0 → 5.5px, white outline; the four step cards hang on the same timeline (opacity .32 → 1 on the same ranges).
Progress curve of a guitar course — online learning platform — Path Drawing on Scroll example 2

② Progress curve of a guitar course — online learning platform

WhenLearning-path page of an online course: the curve rises from the first chords to solos, and four levels (open chords, rhythm, barre chords, improvisation) mark themselves as the student scrolls through the syllabus.
WhyThe curve drawing itself is the progression itself, and the 0-15% / 25-40% / 50-65% / 75-90% ranges are re-tuned in the CSS if a level is added — fx-0781 draws concentric rings, not a trajectory.
Settings50 px header, 310 px container, 1,240 px stage (930 px of travel) as a mirrored grid: steps on the left, 240 px sticky graph on the right; 256-unit cubic path with pathLength="600"; milestones at 0, 37, 63 and 100% of the length; axes and dashed level lines inside the SVG; userSpaceOnUse gradient #38bdf8 → #a78bfa → #f472b6; milestones r 0 → 6px; text levels in opacity on the code's ranges.
Migration route of a bird species — educational page of a nature reserve — Path Drawing on Scroll example 3

③ Migration route of a bird species — educational page of a nature reserve

When“The black kite's migration” sheet: the route draws itself from north to south on a stylised map, and four stops (marshes, estuary, pass, delta) pop up one after the other along the text.
WhyThe curved path and the point stops are exactly the vocabulary of a migration: between two ranges (15-25, 40-50, 65-75%) only the line advances, like the flight between two stops, and a stop once reached stays lit thanks to animation-fill-mode: both — fx-0796 draws nothing, it moves panels.
SettingsPortrait map (viewBox 0 0 200 250) in a 210 px sticky column, 46 px header, 314 px container, 1,260 px stage (946 px of travel); map background = one coastline polygon and three dashed parallels inside the SVG; 240-unit north → south path with pathLength="600", vertical gradient #166534 → #65a30d → #b45309, milestones r 0 → 5.5px with a cream outline; four dated stops hanging on --pathScroll.

How it works

Five boxes: the .demo-preview frame (centered flex, min-height: 300px, position: relative, #0a0a0f background), the scrolling box .pathdraw-container (width:100%;height:300px as an inline style, overflow: hidden auto) which declares scroll-timeline: --pathScroll y, the .pathdraw-spacer stage at 500% (1,500 px, padding-top: 10px), the sticky .pathdraw-sticky (top: 10px, width: 90%, max-width: 360px) and the svg.pathdraw-svg in viewBox 0 0 260 200, width: 100%; height: auto — 360 × 277 px as soon as the frame is wider than 400 px, 337 × 259 px at 390 px. Inside: a three-stop linearGradient#pathGradient (#6366f1, #d946ef, #f59e0b), the path M 20 180 Q 60 20, 130 100 T 240 30 (measured getTotalLength() = 364.42 units, 3-unit stroke, round caps) and four circles with cx/cy but no r. Travel: 1,500 − 300 = 1,200 px. Path and circles take animation-timeline: --pathScroll, written as longhands (animation-duration: auto, linear, fill-mode: both) because the CSS was captured from computed styles. Below the frame, the .slr-scroll-hint label “Scrollez pour dessiner” (absolute, 8 px from the bottom, 11.2 px, white at 40%) bounces 4 px every 2 s. HTML 1.2 KB, CSS 2.4 KB, empty JS field, no site variable, no console error.

The line: stroke-dasharray: 600, stroke-dashoffset: 600, and @keyframes drawPath brings the offset back to 0, linearly over the whole travel — 0.5 unit per scrolled pixel. Measured in 150 px steps: 525, 450, 375, 300, 225, 150, 75, 0. The visible length is 600 − offset; since the path is only 364.42 units long, it is complete at offset 235.6, i.e. 60.7% of the travel (728 px) — the remaining 39% no longer change the line. The gradient sits on the bounding box (diagonal 0,0 → 100%,100%), not along the pen: measured at the end of the travel, start and end are magenta rgb(217, 70, 239), the peak violet rgb(178, 81, 240), the trough pink rgb(226, 97, 169) — neither the blue nor the amber of the stops ever shows on the line.

The milestones: @keyframes dotAppear takes r from 0 to 5px and fill from #6366f1 to #d946ef, and each .pathdraw-dot:nth-of-type(n) gets its own animation-range — 15% of the travel, 180 px per milestone. Measured mid-range (7.5 / 32.5 / 57.5 / 82.5%): r 2.5 px, fill rgb(158, 86, 240). Relative to the pen: milestone 1 sits at the start, the pen passes milestone 2's abscissa at 23.2% (lit from 25 to 40%), milestone 3's at 38.3% (50-65%) and reaches the end at 60.7% while milestone 4 only appears between 75 and 90% — the line runs ahead of the milestones, increasingly, and the last 10% of travel change nothing. Milestones 2 and 3 are not on the curve: 14 and 17 units above it (nearest points (92.5, 73.9) and (165, 126.2)). Two copies on one page: independent timelines (measured 600 and 300 px of offset); the duplicate id="pathGradient" is harmless as long as both gradients are identical.

Accessibility

  • prefers-reduced-motion not in the code: measured under the preference, the label's bounce keeps running (hintBounce running) and the drawing follows the scroll as before. Nothing is hidden: the drawing only moves under the reader's gesture (WCAG 2.3.3, interaction-triggered animation). Add @media (prefers-reduced-motion: reduce) { .slr-scroll-hint { animation: none } }; to deliver the finished drawing without scrolling, repeat the five rules of the @supports not block under that media query.
  • Keyboard: measured in Chromium, Tab focuses the container without a tabindex (outline: auto 1px ring), Arrow Down moves 40 px (3.3% of travel, 20 units of dash), PageDown 302 px, End goes to the bottom — the drawing follows every key. Safari and Firefox do not focus a scrolling box without tabindex="0": add it, with role="region", an aria-label and a :focus-visible style. With a finger, native nested scrolling: the gesture must start inside the box, and overscroll-behavior: contain keeps the page from being dragged along at the end of the travel.
  • Contrast: the 11.2 px label in white at 40% reads 3.77:1 on #0a0a0f (4.5:1 threshold), hard-coded French, absolute relative to the frame — put aria-hidden="true" on it or replace it with a readable instruction. Path and milestones are graphical components (3:1 threshold): #6366f1 4.42:1, #d946ef 5.71:1, #f59e0b 9.2:1 on the frame's background; without that background, on white: 4.47 / 3.46 / 2.15:1, the amber drops below the threshold. A 5-unit milestone is a 6.9 px radius at 360 px wide.
  • Screen readers: the SVG has neither role nor <title>, nothing is announced. Decorative: aria-hidden="true". Informative: role="img" and aria-label, and above all the steps as text in the DOM, next to it — the circles hold no text and only exist visually after scrolling. Where the timeline is missing, the @supports fallback shows the finished drawing and the four milestones without scrolling. No JavaScript: nothing to disable.

Browser compatibility

CSS only, 2.4 KB: a named scroll-timeline on the container, animation-timeline, animation-range per milestone, the animated CSS property r, stroke-dasharray/stroke-dashoffset, position: sticky, overflow: hidden auto. The first three set the threshold: Chrome and Edge 115+, Safari and iOS 26 (measured in WebKit 26.5: rendering identical to Chromium, offset 525 px and r 4.17 px after 150 px). Firefox: no version runs animation-timeline (measured in Firefox 153: CSS.supports('animation-timeline: scroll()') = false). Zero dependencies, zero JavaScript.

Chrome 115+✓ Full
Firefox✓ No version — fallback: complete line and four milestones in place, no scrolling (measured in Firefox 153)
Safari 26+✓ Full (before: flat fallback)
Edge 115+✓ Full
Mobile iOS 26+✓ Full (touch scrolling inside the box; before: flat fallback)
Android Chrome 115+✓ Full

The @supports not (animation-timeline: scroll()) block takes over wherever the timeline is missing (Firefox, Safari and iOS before 26, Chrome before 115): animation: none on the path and milestones, stroke-dashoffset: 0, r: 5px and fill: #d946ef on the four circles, height: auto on the stage, display: none on the label. Measured in Firefox 153: complete line, four 13.9 px milestones, scrollHeight 300 = clientHeight (nothing to scroll), label hidden — the visitor sees the finished drawing, flat, without a “scroll to draw” instruction that would draw nothing. Without this block, Firefox reached the same offset 0 through a 0 s animation, but with 1,200 px of dead travel and a bouncing label. The unit in r: 5px is not decorative: CSS.supports('r: 5') is false in WebKit and Firefox — only Chrome accepts the bare value — and without it the milestones stay at r: 0, invisible (measured in WebKit 26.5: line drawn, no milestone).

The code

HTML structure to paste into your page (the CSS + JS come with Premium):

index.html — structure
<div class="demo-preview">
  <div class="pathdraw-container">
    <div class="pathdraw-spacer">
      <div class="pathdraw-sticky">
        <svg class="pathdraw-svg" viewBox="0 0 260 200">
          <defs>
            <linearGradient id="pathGradient">…</linearGradient>
          </defs>
          <path class="pathdraw-path" d="…"></path>
          <circle class="pathdraw-dot" cx="…" cy="…"></circle>
          <circle class="pathdraw-dot" cx="…" cy="…"></circle>
          <circle class="pathdraw-dot" cx="…" cy="…"></circle>
          <circle class="pathdraw-dot" cx="…" cy="…"></circle>
        </svg>
      </div>
    </div>
  </div>
  <span class="slr-scroll-hint">…</span>
</div>
Unlock the full code — €9.90/mo excl. tax

The full HTML + CSS + JS of Path Drawing on Scroll, ready to paste — plus all 811 effects. Or €299 once, lifetime access.

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Path (HTML — d attribute of .pathdraw-path) M 20 180 Q 60 20, 130 100 T 240 30 (364 units) Any SVG path. Beyond 600 units the line never finishes: raise stroke-dasharray, stroke-dashoffset and the 600 of drawPath, or put pathLength="600" on the path (measured in Chromium, WebKit and Firefox: the dash is then counted over 600 whatever the real length).
Dash length (CSS — stroke-dasharray: 600, stroke-dashoffset: 600, @keyframes drawPath) 600 → line complete at 60.7% of the travel The line finishes when 600 − offset reaches the path length. With 365 in all three places (or pathLength="600"), the pen reaches the end at 100%, and passes the abscissas of milestones 2 and 3 at 38 and 63% instead of 23 and 38.
Travel (HTML — inline height:300px on the container; CSS — .pathdraw-spacer { height: 500% }) 1,200 px (0.5 unit per pixel) Travel = stage − container. 300% gives 600 px, twice as brisk. The stage is also the sticky containing block: with a 277 px SVG and top: 10px, the drawing stays pinned up to 1,203 px, hence over the whole shipped travel.
Milestone ranges (CSS — .pathdraw-dot:nth-of-type(n) { animation-range }) 0% 15% · 25% 40% · 50% 65% · 75% 90% Each range is 180 px of travel; 10% stay inert after the last milestone. Tighten (75% 80%) for a sharp switch-on, or 60% 75% on the fourth so it coincides with the pen arriving at 60.7%.
Milestones (HTML — cx/cy; CSS — @keyframes dotAppear) r 0 → 5px, fill #6366f1 → #d946ef 5 units = 6.9 px radius at 360 px. Keep the unit on r: the bare value is only accepted by Chrome. Shipped milestones 2 and 3 are 14 and 17 units above the curve; path.getPointAtLength(l) in the console gives the exact point to put in cx/cy.
Gradient (SVG — #pathGradient, three stops) #6366f1 → #d946ef → #f59e0b, diagonal of the bounding box The gradient follows the box, not the pen: on the shipped curve you only see violet → pink. For a gradient along the route, gradientUnits="userSpaceOnUse" x1="20" x2="240"; for a flat color, stroke: #d946ef. The id is global: two instances with different colors need two ids.
Size and pin point (CSS — .pathdraw-sticky { width: 90%; max-width: 360px; top: 10px }, stroke-width: 3) 360 × 277 px at most, pinned 10 px from the top The max-width keeps the drawing inside the 300 px window: without it, at 1,280 px wide, the SVG is 875 px tall and only a third of the path is visible. For a taller container, raise both together (SVG height = width × 200 ÷ 260, to keep under container − 20). The 3-unit stroke measures 4.2 px at 360 px.

FAQ

Because the dash is set to 600 units for a 364.42-unit path. The offset goes linearly from 600 to 0 over the 1,200 px; the visible length is 600 − offset, so the line is finished from offset 235.6 — 60.7% of the travel, 728 px — and nothing changes on it during the next 472 px, while milestones 3 and 4 light up. Two ways to resynchronise, both measured: write 365 in stroke-dasharray, stroke-dashoffset and the 0% of drawPath, or put pathLength="600" on the path — the attribute normalises the path length to 600 for the dash computation (at offset 300, the pen is halfway in Chromium, WebKit and Firefox). The pen then reaches the end at 100% and passes milestones 2 and 3 at 38 and 63%, just before they finish lighting at 40 and 65%. The three scenes on this page use pathLength.
The engine. fx-0781 is JavaScript: a scroll listener under requestAnimationFrame writes the progress into a --p variable, and five rings compute their stroke-dasharray with calc() from that variable, rotate their hue in oklch and display a percentage — everything advances together, proportionally, and it works in Firefox since no scroll-timeline is involved. fx-0572 has not a line of script: the named timeline is declared on the container and, above all, each milestone gets its own animation-range — point events (0-15, 25-40, 50-65, 75-90%) laid on a continuous line, which a single --p does not give without a condition per ring. The price: Firefox runs no version of the timeline (flat fallback, finished drawing) and the page has no number to read — no counter, no button to unlock. fx-0781 for a global, numbered progress that must run everywhere; fx-0572 for dated steps on a route.
One milestone = one more circle with its cx/cy (no r, the CSS carries it) and a rule .pathdraw-dot:nth-of-type(5) { animation-range: … }; the ranges are free, they only need to follow each other — five 12% slots 10% apart: 0% 12%, 22% 34%, 44% 56%, 66% 78%, 88% 100%. The nth-of-type counter only counts circles: the path shifts nothing. For positions, cx/cy are attributes set by hand — shipped milestones 2 and 3 float 14 and 17 units above the curve. In the console, const p = document.querySelector('.pathdraw-path'); p.getPointAtLength(p.getTotalLength() * 0.38) returns the point at 38% of the path (the one the pen reaches at 38% with pathLength="600"): copy x and y. If the new path exceeds 600 units, raise the dash or set pathLength, otherwise the line stops before the last milestone.