Copied!
Background

SVG shapes that draw and morph on scroll

Inline SVG shapes that draw their outlines, travel and morph between two blob forms as you scroll inside the element's own scroll container.

← Library

Element · Vanilla JS + CSS + SVGUpdated November 6, 2026

ScrollElementVanilla JS + CSS + SVG

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.

SectionBackground
TechniquesScroll-driven, Reveal and intro
StackVanilla JS, CSS, SVG
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
Inspired byHow 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.

Mask shape sectionsThe same image revealed through a circle, a diamond and a band as each section scrolls into view. Image compare on scrollA before and after slider whose divider is scrubbed by scroll position and can also be dragged or keyboard-operated. Unroll image on scrollA poster that unrolls like a sheet of paper from a spiral roll as you scroll or hover.

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.