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.
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



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 notblock under that media query. - Keyboard: measured in Chromium, Tab focuses the container without a
tabindex(outline: auto 1pxring), 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 withouttabindex="0": add it, withrole="region", anaria-labeland a:focus-visiblestyle. With a finger, native nested scrolling: the gesture must start inside the box, andoverscroll-behavior: containkeeps 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 — putaria-hidden="true"on it or replace it with a readable instruction. Path and milestones are graphical components (3:1 threshold):#6366f14.42:1,#d946ef5.71:1,#f59e0b9.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
rolenor<title>, nothing is announced. Decorative:aria-hidden="true". Informative:role="img"andaria-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@supportsfallback 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.
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):
<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>
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 / property | Default | Effect |
|---|---|---|
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
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.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.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.