Copied!
Background

Line waves with pointer swell

Dozens of thin horizontal lines that undulate as travelling waves, with a smooth local swell that rises under the pointer and floating Enrichr puzzle pieces on top.

← Library

Element · Vanilla JS + CanvasUpdated November 6, 2026

BackgroundElementVanilla JS + Canvas

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 "Line waves with pointer swell" for my site using the Enrichr Library element at https://enrichr.dev/elements/line-waves.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/line-waves.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:
- lineColor: #e6ff55
- lines: 46
- amplitude: 22
- speed: 1
- swell: 46

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/line-waves.json.

Code

Mount it on an element that contains the markup. It returns an object with update(options) and destroy().

HTML

<div class="ef-lw">
  <canvas class="ef-lw__c" role="img" aria-label="Many thin horizontal lines undulating as travelling waves, with a swell that follows the pointer"></canvas>
  <svg class="ef-lw__defs" width="0" height="0" aria-hidden="true" focusable="false"><defs><symbol id="ef-lw-p1" viewBox="1513.7 1453.9 322.3 322.3"><path fill="currentColor" d="M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"/></symbol><symbol id="ef-lw-p4" viewBox="1423.36 860.8 503.07 322.3"><path fill="currentColor" d="M1821 875.8v111.6c0 8 6.5 14.5 14.5 14.5 4.2 0 8.1-1.9 10.9-5 6.8-7.5 16.7-12.3 27.7-12.3 22 0 39.6 19 37.1 41.6-1.9 16.7-15.2 30.5-32 32.7-13 1.7-24.8-3.3-32.7-11.9-2.8-3.2-6.8-5-11-5-8 0-14.5 6.4-14.5 14.4v111.7h-111.6c-8 0-14.5-6.5-14.5-14.5 0-4.2 1.9-8.2 5-11 8.7-7.9 13.7-19.7 12-32.7-2.3-16.8-16-30.1-32.8-32-22.5-2.5-41.5 15.1-41.5 37.1 0 11 4.7 20.9 12.3 27.7 3.1 2.8 4.9 6.7 4.9 10.8v0.1c0 8-6.4 14.5-14.5 14.5h-111.6v-111.7c0-8-6.4-14.4-14.5-14.4-4.1 0-8 1.8-10.8 4.9-6.9 7.6-16.7 12.3-27.7 12.3-22 0-39.6-19-37.1-41.6 1.9-16.7 15.2-30.4 32-32.7 13-1.7 24.8 3.3 32.7 11.9 2.8 3.2 6.7 5.1 10.9 5.1 8.1 0 14.5-6.5 14.5-14.5v-111.6z"/></symbol><symbol id="ef-lw-p8" viewBox="1513.7 166.86 322.3 503.06"><path fill="currentColor" d="M1528.7 564.5v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5 7.8 8.7 19.7 13.7 32.7 11.9 16.8-2.2 30.1-15.9 32-32.7 2.5-22.5-15.1-41.5-37.1-41.5-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.5-14.5-14.5v-111.6h111.6c8.1 0 14.5-6.5 14.5-14.5 0-4.1-1.8-8.1-4.9-10.9-7.6-6.8-12.3-16.6-12.3-27.6 0-22 19-39.6 41.5-37.1 16.8 1.8 30.5 15.2 32.8 32 1.7 12.9-3.3 24.8-12 32.7-3.1 2.8-5 6.7-5 10.9 0 8 6.5 14.5 14.5 14.5h111.6v111.6c0 8-6.4 14.5-14.5 14.5-4.2 0-8.1-1.9-10.9-5-7.9-8.7-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.5 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5v111.6h-111.6c-8 0-14.5 6.5-14.5 14.5 0 4.2 1.9 8.1 5 10.9 7.5 6.8 12.3 16.7 12.3 27.7 0 22-19 39.5-41.6 37.1-16.8-1.9-30.5-15.3-32.7-32-1.7-13 3.3-24.9 11.9-32.7 3.1-2.9 5-6.8 5-11 0-8-6.4-14.5-14.5-14.5z"/></symbol></defs></svg>
  <div class="ef-lw__pieces" aria-hidden="true"><svg class="ef-lw__pc ef-lw__pc--a" aria-hidden="true" focusable="false"><use href="#ef-lw-p1" width="100%" height="100%"/></svg><svg class="ef-lw__pc ef-lw__pc--b" aria-hidden="true" focusable="false"><use href="#ef-lw-p4" width="100%" height="100%"/></svg><svg class="ef-lw__pc ef-lw__pc--c" aria-hidden="true" focusable="false"><use href="#ef-lw-p8" width="100%" height="100%"/></svg></div>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-lw { position: relative; width: 100%; height: 100%; min-height: 18rem; overflow: hidden; background: #0c1e18; isolation: isolate; }
.ef-lw__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ef-lw__defs { position: absolute; width: 0; height: 0; overflow: hidden; }
.ef-lw__pieces { position: absolute; inset: 0; pointer-events: none; z-index: 2; }
.ef-lw__pc { position: absolute; display: block; filter: drop-shadow(0 10px 14px rgba(0, 0, 0, 0.35)); will-change: transform; }
.ef-lw__pc--a { width: min(22%, 6.5rem); aspect-ratio: 1; left: 9%; top: 14%; color: var(--ef-pa, #e6ff55); animation: ef-lw-f1 9s ease-in-out infinite; }
.ef-lw__pc--b { width: min(34%, 9rem); aspect-ratio: 503 / 322; right: 8%; bottom: 16%; color: var(--ef-pb, #fbf8f1); opacity: 0.92; animation: ef-lw-f2 11s ease-in-out infinite; }
.ef-lw__pc--c { width: min(15%, 4.4rem); aspect-ratio: 322 / 503; right: 22%; top: 9%; color: var(--ef-pc, #2f7d5b); animation: ef-lw-f3 13s ease-in-out infinite; }
.ef-lw.no-pieces .ef-lw__pieces { display: none; }
@keyframes ef-lw-f1 { 0%, 100% { transform: translate3d(0, 0, 0) rotate(-12deg); } 50% { transform: translate3d(8px, -14px, 0) rotate(-4deg); } }
@keyframes ef-lw-f2 { 0%, 100% { transform: translate3d(0, 0, 0) rotate(8deg); } 50% { transform: translate3d(-12px, 10px, 0) rotate(15deg); } }
@keyframes ef-lw-f3 { 0%, 100% { transform: translate3d(0, 0, 0) rotate(18deg); } 50% { transform: translate3d(6px, 12px, 0) rotate(8deg); } }
@media (prefers-reduced-motion: reduce) { .ef-lw__pc { animation: none; } .ef-lw__pc--a { transform: rotate(-10deg); } .ef-lw__pc--b { transform: rotate(8deg); } .ef-lw__pc--c { transform: rotate(16deg); } }

JavaScript

// Enrichr Library · https://enrichr.dev
// Line waves: many thin horizontal lines that undulate as travelling waves (a few summed sines per line, phase-shifted down the stack). The pointer adds a smooth local swell that lifts the lines near it.
// An original rebuild from the general idea. Reduced motion draws one still frame. The loop pauses off screen. Puzzle-piece art floats on top.
// mount(root, { background, lineColor, lines (10-90), amplitude (0-60 px), speed (0-3), swell (0-120 px), pieces ("on"|"off") })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-lw");
  var canvas = root.querySelector(".ef-lw__c");
  var ctx = canvas.getContext("2d");
  var opts = {}, raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0, t = 0;
  var W = 1, H = 1, dpr = 1;
  var px = 0, py = 0, tx = 0, ty = 0, hasPtr = false, strength = 0, tStrength = 0;
  var ca = [10, 107, 70], cb = [230, 255, 85];

  function rgb(h, d) { var m = /^#?([0-9a-f]{6})$/i.exec(h || ""); var v = parseInt(m ? m[1] : d, 16); return [v >> 16, (v >> 8) & 255, v & 255]; }
  function size() {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = Math.max(1, wrap.clientWidth); H = Math.max(1, wrap.clientHeight);
    canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }
  function draw() {
    ctx.clearRect(0, 0, W, H);
    var n = opts.lines, amp = opts.amplitude, sw = opts.swell;
    var top = H * 0.08, span = H * 0.84, step = Math.max(4, Math.round(W / 140));
    var sig = Math.max(40, W * 0.16), sigY = Math.max(30, H * 0.18);
    ctx.lineJoin = "round"; ctx.lineWidth = 1.1;
    for (var i = 0; i < n; i++) {
      var f = n > 1 ? i / (n - 1) : 0;
      var by = top + f * span;
      // colour runs from deep green at the top to lime at the bottom, cream-ish where the swell lifts it
      var r = ca[0] + (cb[0] - ca[0]) * f, g = ca[1] + (cb[1] - ca[1]) * f, b = ca[2] + (cb[2] - ca[2]) * f;
      ctx.strokeStyle = "rgba(" + (r | 0) + "," + (g | 0) + "," + (b | 0) + "," + (0.35 + 0.55 * f).toFixed(3) + ")";
      ctx.beginPath();
      for (var x = 0; x <= W + step; x += step) {
        var u = x / W;
        var y = by
          + amp * 0.62 * Math.sin(u * 6.4 - t * 1.1 + i * 0.16)
          + amp * 0.28 * Math.sin(u * 13.7 + t * 0.7 - i * 0.27)
          + amp * 0.18 * Math.sin(u * 3.1 - t * 0.5 + i * 0.05);
        if (strength > 0.001) {
          var dx = (x - px) / sig, dy = (by - py) / sigY;
          y -= sw * strength * Math.exp(-(dx * dx + dy * dy));
        }
        if (x === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
      }
      ctx.stroke();
    }
  }
  function frame(now) {
    raf = 0;
    if (destroyed || !visible) return;
    var dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016;
    last = now;
    t += dt * opts.speed;
    px += (tx - px) * Math.min(1, dt * 9); py += (ty - py) * Math.min(1, dt * 9);
    strength += (tStrength - strength) * Math.min(1, dt * 5);
    draw();
    raf = requestAnimationFrame(frame);
  }
  function start() { if (!reduce && !raf && visible && !destroyed) { last = 0; raf = requestAnimationFrame(frame); } }
  function stop() { if (raf) cancelAnimationFrame(raf); raf = 0; }
  function still() { if (reduce) { t = 2.4; draw(); } }
  function mv(e) {
    var r = canvas.getBoundingClientRect();
    tx = e.clientX - r.left; ty = e.clientY - r.top;
    if (!hasPtr) { px = tx; py = ty; hasPtr = true; }
    tStrength = 1;
    if (reduce) { px = tx; py = ty; strength = 1; draw(); }
  }
  function lv() { tStrength = 0; if (reduce) { strength = 0; draw(); } }
  function update(next) {
    opts = Object.assign({ background: "#0c1e18", lineColor: "#e6ff55", lines: 46, amplitude: 22, speed: 1, swell: 46, pieces: "on" }, opts, next || {});
    opts.lines = Math.max(10, Math.min(90, Math.round(opts.lines)));
    wrap.style.background = opts.background;
    cb = rgb(opts.lineColor, "e6ff55");
    wrap.classList.toggle("no-pieces", String(opts.pieces) === "off");
    if (!raf) draw();
    if (reduce) still(); else start();
  }
  size();
  update(o);
  canvas.addEventListener("pointermove", mv);
  canvas.addEventListener("pointerleave", lv);
  wrap.addEventListener("pointermove", mv);
  wrap.addEventListener("pointerleave", lv);
  if (typeof ResizeObserver !== "undefined") { ro = new ResizeObserver(function () { size(); draw(); }); ro.observe(wrap); }
  if (typeof IntersectionObserver !== "undefined") {
    io = new IntersectionObserver(function (es) { visible = es[0].isIntersecting; if (visible) start(); else stop(); });
    io.observe(wrap);
  }
  return {
    update: update,
    destroy: function () {
      destroyed = true; stop();
      canvas.removeEventListener("pointermove", mv); canvas.removeEventListener("pointerleave", lv);
      wrap.removeEventListener("pointermove", mv); wrap.removeEventListener("pointerleave", lv);
      if (ro) ro.disconnect(); if (io) io.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"lineColor":"#e6ff55","lines":46,"amplitude":22,"speed":1,"swell":46});

How it works

Each line is a polyline sampled every few pixels across the canvas. Its height is the line's resting y plus three sine waves of different frequency; time moves the phase so the waves travel, and the line index shifts the phase so neighbouring lines ripple in sequence. The pointer position is eased toward the real pointer, and a gaussian bump around it is subtracted from y, so lines near the pointer lift smoothly and settle again when it leaves. Colour runs from deep green at the top to the accent at the bottom. The puzzle pieces are inline SVG symbols floated with CSS keyframes.

Accessibility and performance

The canvas is a decorative image with a text label and the puzzle pieces are hidden from assistive tech. Nothing is focusable. Reduced motion draws one still frame, stops the piece float and keeps the pointer swell as an instant response with no easing. Keep any text you overlay high in contrast, as the lines pass behind it.

One canvas, one stroked path per line per frame (about 50 paths of roughly 140 points). The loop pauses when the element is off screen and the pixel ratio is capped at 2. Lower the line count on very large hero areas.

SectionBackground
TechniquesPointer interaction
StackVanilla JS, Canvas
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byLine Waves (React Bits), general effect only by React Bits on reactbits.dev. 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.

Rule sweep listA line sweeps across a menu row on hover or focus and exits through the opposite side. Depth parallax cardLayered shapes shift by different amounts as the pointer moves, drifting on touch screens. 3D ring carouselCards on a CSS 3D ring that rotates one step at a time, with buttons, arrow keys and autoplay.

FAQ

Can I change the line colours?

Set the line colour parameter; lines blend from the brand green at the top to that colour at the bottom.

How do I remove the puzzle pieces?

Set Puzzle pieces to Hide, or delete the pieces div from the markup.

Does it work in a clipped box?

Yes. It sizes itself to its container with a ResizeObserver and the container can clip it with overflow hidden.