Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "SVG shapes that draw and morph on scroll" for my site using the Enrichr Library element at https://enrichr.dev/elements/svg-shapes-scroll.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/svg-shapes-scroll.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- accent: #e6ff55
- strokeWidth: 3
- morph: 1
- spin: 120
- smooth: 0.8
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/svg-shapes-scroll.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-sss">
<div class="ef-sss__scroller" tabindex="0" role="region" aria-label="Scroll to draw and morph the shapes">
<div class="ef-sss__track">
<div class="ef-sss__stage">
<svg class="ef-sss__svg" viewBox="0 0 400 260" preserveAspectRatio="xMidYMid meet" aria-hidden="true" focusable="false">
<defs>
<linearGradient class="ef-sss__grad" id="ef-sss-g" x1="0" y1="0" x2="1" y2="1">
<stop class="ef-sss__s1" offset="0" stop-color="#e6ff55"></stop>
<stop class="ef-sss__s2" offset="1" stop-color="#005236"></stop>
</linearGradient>
</defs>
<path class="ef-sss__wave" pathLength="1" d="M20 200 C 80 120, 120 280, 190 190 S 300 90, 380 170"></path>
<path class="ef-sss__blob" d=""></path>
<circle class="ef-sss__ring" pathLength="1" cx="60" cy="60" r="22"></circle>
<circle class="ef-sss__dot" cx="60" cy="60" r="6"></circle>
<path class="ef-sss__tri" pathLength="1" d="M200 60 L 250 150 L 150 150 Z"></path>
</svg>
<div class="ef-sss__hud"><span class="ef-sss__hint">Scroll</span><span class="ef-sss__bar"><i class="ef-sss__fill"></i></span></div>
</div>
</div>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-sss { width: 100%; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-sss__scroller { --ef-acc: #e6ff55; --ef-sw: 3; position: relative; box-sizing: border-box; height: 20rem; max-height: 100%; overflow-y: auto; overflow-x: hidden; border-radius: 1rem; background: #0c1e18; color: #fbf8f1; outline: none; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: #005236 transparent; }
.ef-sss__scroller:focus-visible { box-shadow: inset 0 0 0 2px var(--ef-acc); }
.ef-sss__track { height: 520%; position: relative; }
.ef-sss__stage { position: sticky; top: 0; height: 20rem; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.ef-sss__svg { width: 100%; height: 100%; display: block; overflow: hidden; }
.ef-sss__wave, .ef-sss__tri, .ef-sss__ring { fill: none; stroke: var(--ef-acc); stroke-width: var(--ef-sw); stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; }
.ef-sss__ring { stroke: #fbf8f1; opacity: 0.85; }
.ef-sss__blob { opacity: 0; transform-box: fill-box; transform-origin: center; }
.ef-sss__dot { fill: var(--ef-acc); }
.ef-sss__hud { position: absolute; left: 1rem; right: 1rem; bottom: 0.8rem; display: flex; align-items: center; gap: 0.7rem; pointer-events: none; font-size: 0.68rem; letter-spacing: 0.14em; text-transform: uppercase; opacity: 0.85; }
.ef-sss__bar { flex: 1; height: 2px; background: rgba(251, 248, 241, 0.2); border-radius: 2px; overflow: hidden; }
.ef-sss__fill { display: block; height: 100%; width: 100%; background: var(--ef-acc); transform: scaleX(0); transform-origin: left; }
@media (prefers-reduced-motion: reduce) { .ef-sss__scroller { scroll-behavior: auto; } }JavaScript
// Enrichr Library · https://enrichr.dev
// SVG shapes on scroll: a wave and a triangle draw themselves (stroke-dashoffset), a ring travels with a dot, and a blob morphs
// between two radius sets while it rotates, all driven by the scroll progress of the element's own scroller (not the page).
// Progress is smoothed in a rAF loop that stops itself when settled; reduced motion maps scroll to shapes directly with no easing.
// mount(root, { background, accent, strokeWidth (1-8), morph (0-1), spin (deg, 0-360), smooth (0-0.9) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var sc = root.querySelector(".ef-sss__scroller");
var q = function (s) { return root.querySelector(s); };
var wave = q(".ef-sss__wave"), blob = q(".ef-sss__blob"), ring = q(".ef-sss__ring"), dot = q(".ef-sss__dot"), tri = q(".ef-sss__tri"), fill = q(".ef-sss__fill");
var s1 = q(".ef-sss__s1");
var opts = {}, target = 0, cur = 0, raf = 0;
var A = [58, 44, 62, 40, 60, 46, 64, 42], B = [38, 64, 36, 66, 40, 62, 38, 60];
var CX = 290, CY = 130;
function clamp(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
function ease(t) { return t * t * (3 - 2 * t); }
function seg(p, a, b) { return ease(clamp((p - a) / (b - a))); }
function blobPath(m) {
var n = A.length, pts = [], i;
for (i = 0; i < n; i++) {
var r = A[i] + (B[i] - A[i]) * m, ang = (i / n) * Math.PI * 2;
pts.push([CX + Math.cos(ang) * r, CY + Math.sin(ang) * r]);
}
var d = "M" + pts[0][0].toFixed(1) + " " + pts[0][1].toFixed(1);
for (i = 0; i < n; i++) {
var p0 = pts[(i - 1 + n) % n], p1 = pts[i], p2 = pts[(i + 1) % n], p3 = pts[(i + 2) % n];
d += " C" + (p1[0] + (p2[0] - p0[0]) / 6).toFixed(1) + " " + (p1[1] + (p2[1] - p0[1]) / 6).toFixed(1) + "," +
(p2[0] - (p3[0] - p1[0]) / 6).toFixed(1) + " " + (p2[1] - (p3[1] - p1[1]) / 6).toFixed(1) + "," +
p2[0].toFixed(1) + " " + p2[1].toFixed(1);
}
return d + "Z";
}
function render(p) {
wave.style.strokeDashoffset = String(1 - seg(p, 0, 0.4));
ring.style.strokeDashoffset = String(1 - seg(p, 0.05, 0.35));
var t = seg(p, 0.1, 0.75);
var x = 60 + 120 * t, y = 60 + 40 * Math.sin(t * Math.PI);
ring.setAttribute("cx", x.toFixed(1)); ring.setAttribute("cy", y.toFixed(1));
dot.setAttribute("cx", x.toFixed(1)); dot.setAttribute("cy", y.toFixed(1));
dot.setAttribute("r", (3 + 7 * seg(p, 0.1, 0.5)).toFixed(1));
blob.setAttribute("d", blobPath(seg(p, 0.25, 0.85) * opts.morph));
blob.style.opacity = String(0.9 * seg(p, 0.1, 0.3));
blob.style.transform = "rotate(" + (p * opts.spin).toFixed(1) + "deg) scale(" + (0.5 + 0.5 * seg(p, 0.15, 0.5)).toFixed(3) + ")";
tri.style.strokeDashoffset = String(1 - seg(p, 0.55, 0.95));
fill.style.transform = "scaleX(" + p.toFixed(3) + ")";
}
function readTarget() {
var max = sc.scrollHeight - sc.clientHeight;
target = max > 0 ? clamp(sc.scrollTop / max) : 0;
}
function tick() {
raf = 0;
cur += (target - cur) * (1 - opts.smooth);
if (Math.abs(target - cur) < 0.0005) cur = target;
render(cur);
if (cur !== target) raf = requestAnimationFrame(tick);
}
function onScroll() {
readTarget();
if (reduce || opts.smooth <= 0) { cur = target; render(cur); return; }
if (!raf) raf = requestAnimationFrame(tick);
}
function update(next) {
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", strokeWidth: 3, morph: 1, spin: 120, smooth: 0.8 }, opts, next || {});
opts.smooth = Math.max(0, Math.min(0.95, opts.smooth));
sc.style.background = opts.background;
sc.style.setProperty("--ef-acc", opts.accent);
sc.style.setProperty("--ef-sw", String(opts.strokeWidth));
s1.setAttribute("stop-color", opts.accent);
readTarget();
if (reduce || opts.smooth <= 0) cur = target;
render(cur);
if (cur !== target && !raf) raf = requestAnimationFrame(tick);
}
// unique gradient id so several instances on one page do not collide
var uid = "ef-sss-g" + Math.random().toString(36).slice(2, 8);
q(".ef-sss__grad").id = uid;
blob.style.fill = "url(#" + uid + ")";
sc.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
update(o);
cur = target; render(cur);
return {
update: update,
destroy: function () {
sc.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
if (raf) cancelAnimationFrame(raf);
raf = 0;
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","strokeWidth":3,"morph":1,"spin":120,"smooth":0.8});How it works
The element owns a short scroll container with a tall track inside it and a sticky stage that holds the SVG. A scroll listener turns scrollTop into a 0 to 1 progress value. Each shape reads its own slice of that progress through a smoothstep: the wave and triangle use pathLength=1 with stroke-dasharray 1 and a stroke-dashoffset that falls from 1 to 0, so they draw themselves; the ring and dot slide along a curve; the blob is rebuilt every frame from eight radii that interpolate between two sets and are joined by smooth cubic curves, so the shape morphs without needing matching path strings. Progress is eased in a small requestAnimationFrame loop that stops itself once it has caught up with the scroll position.
Accessibility and performance
The scroller is focusable and labelled, so keyboard users can scroll it with arrow keys, Page Down and Space, and a visible focus ring marks it. The SVG is decorative and hidden from assistive technology. With reduced motion the easing loop is skipped and shapes follow scroll position directly, so nothing moves unless the user scrolls. If the shapes carry meaning, add a text alternative next to the element.
Each frame writes a handful of SVG attributes and one path string for the blob, and only while progress is still catching up. The scroll listener is passive and does no layout reads beyond scrollTop, scrollHeight and clientHeight. Keep the shape count modest and avoid filters or blur on the SVG.
| Section | Background |
|---|---|
| Techniques | Scroll-driven, Reveal and intro |
| Stack | Vanilla JS, CSS, SVG |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | How to Animate SVG Shapes on Scroll by Codrops on Codrops. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Does it work with page scroll instead of an inner scroller?
Yes. Replace the scroller with the window and compute progress from the section's bounding rect against the viewport. The shape code does not change.
How do I draw my own path?
Give the path pathLength=1 and a stroke-dasharray of 1, then drive stroke-dashoffset from 1 to 0 with the progress slice you want.
Can I morph between my own shapes?
Change the two radius arrays at the top of the script for blobs, or use two paths with the same number and type of commands and interpolate their numbers.