Copied!
Background

Threads weave with trails

Thin threads drift and weave through a smooth noise field, leaving fading trails, and are nudged aside by the pointer, with Enrichr puzzle pieces floating 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 "Threads weave with trails" for my site using the Enrichr Library element at https://enrichr.dev/elements/threads-weave.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/threads-weave.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
- threads: 110
- trail: 0.8
- speed: 1

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/threads-weave.json.

Code

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

HTML

<div class="ef-tw">
  <canvas class="ef-tw__c" role="img" aria-label="Thin threads drifting and weaving along noise-driven paths and leaving trails"></canvas>
  <svg class="ef-tw__defs" width="0" height="0" aria-hidden="true" focusable="false"><defs><symbol id="ef-tw-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-tw-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-tw-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-tw__pieces" aria-hidden="true"><svg class="ef-tw__pc ef-tw__pc--a" aria-hidden="true" focusable="false"><use href="#ef-tw-p1" width="100%" height="100%"/></svg><svg class="ef-tw__pc ef-tw__pc--b" aria-hidden="true" focusable="false"><use href="#ef-tw-p4" width="100%" height="100%"/></svg><svg class="ef-tw__pc ef-tw__pc--c" aria-hidden="true" focusable="false"><use href="#ef-tw-p8" width="100%" height="100%"/></svg></div>
</div>

CSS

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

JavaScript

// Enrichr Library · https://enrichr.dev
// Threads weave: thin threads that drift through a smooth noise field and leave fading trails. Half the threads follow the field (warp), half cross it at a right angle (weft), so the paths weave over each other.
// The pointer pushes nearby threads aside. An original rebuild from the general idea. Reduced motion pre-runs the threads and shows one still frame. The loop pauses off screen.
// mount(root, { background, accent, threads (20-200), trail (0-1), speed (0-3), turbulence (0.2-3), pieces ("on"|"off") })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-tw");
  var canvas = root.querySelector(".ef-tw__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, n = 0, X, Y, PX, PY, AGE, KIND, seed = 90211;
  var mx = -9999, my = -9999, hasPtr = false, bg = [12, 30, 24], acc = [230, 255, 85];
  var PAL = [[10, 107, 70], [47, 125, 91], [198, 242, 107], [251, 248, 241]];

  function rnd() { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 4294967296; }
  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 hash(i, j) { var h = (i * 374761393 + j * 668265263) | 0; h = (h ^ (h >>> 13)) * 1274126177 | 0; return ((h ^ (h >>> 16)) >>> 0) / 4294967296; }
  function noise(x, y) {
    var xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
    var u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
    var a = hash(xi, yi), b = hash(xi + 1, yi), c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
    return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
  }
  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);
    fillBg(1);
    for (var i = 0; i < n; i++) respawn(i, true);
  }
  function fillBg(a) { ctx.fillStyle = "rgba(" + bg[0] + "," + bg[1] + "," + bg[2] + "," + a + ")"; ctx.fillRect(0, 0, W, H); }
  function respawn(i, anywhere) {
    if (anywhere) { X[i] = rnd() * W; Y[i] = rnd() * H; }
    else if (KIND[i] % 2 === 0) { X[i] = rnd() < 0.5 ? -2 : W + 2; Y[i] = rnd() * H; }
    else { X[i] = rnd() * W; Y[i] = rnd() < 0.5 ? -2 : H + 2; }
    PX[i] = X[i]; PY[i] = Y[i]; AGE[i] = rnd() * 400;
  }
  function populate() {
    if (n === opts.threads && X) return;
    n = opts.threads;
    X = new Float32Array(n); Y = new Float32Array(n); PX = new Float32Array(n); PY = new Float32Array(n); AGE = new Float32Array(n); KIND = new Uint8Array(n);
    for (var i = 0; i < n; i++) { KIND[i] = (rnd() * 8) | 0; respawn(i, true); }
  }
  function step(dt) {
    var sp = opts.speed * 80, tb = opts.turbulence, sc = 0.0032 * (0.6 + tb * 0.5), tt = t * 0.06;
    var pr = Math.max(60, Math.min(W, H) * 0.22), pr2 = pr * pr;
    for (var i = 0; i < n; i++) {
      var warp = KIND[i] % 2 === 0;
      var a = noise(X[i] * sc + tt, Y[i] * sc - tt) * 6.2832 * tb;
      if (!warp) a += 1.5708 + noise(Y[i] * sc * 1.6, X[i] * sc * 1.6 + tt) * 1.4;
      var vx = Math.cos(a) * sp, vy = Math.sin(a) * sp;
      if (hasPtr) {
        var dx = X[i] - mx, dy = Y[i] - my, d2 = dx * dx + dy * dy;
        if (d2 < pr2 && d2 > 1) { var d = Math.sqrt(d2), p = (1 - d / pr); vx += dx / d * p * 150; vy += dy / d * p * 150; }
      }
      PX[i] = X[i]; PY[i] = Y[i];
      X[i] += vx * dt; Y[i] += vy * dt; AGE[i] += 1;
      if (X[i] < -6 || X[i] > W + 6 || Y[i] < -6 || Y[i] > H + 6 || AGE[i] > 700) respawn(i, false);
    }
  }
  function draw() {
    // one batched path per palette colour keeps the stroke count tiny
    ctx.lineWidth = 1.4; ctx.lineCap = "round";
    for (var c = 0; c < 4; c++) {
      var col = c === 2 ? acc : PAL[c];
      ctx.strokeStyle = "rgba(" + col[0] + "," + col[1] + "," + col[2] + "," + (c === 3 ? 0.7 : c === 2 ? 0.95 : 0.9) + ")";
      ctx.beginPath();
      for (var i = 0; i < n; i++) {
        if ((KIND[i] >> 1) % 4 !== c) continue;
        var dx = X[i] - PX[i], dy = Y[i] - PY[i];
        if (dx * dx + dy * dy > 900) continue; // skip respawn jumps
        ctx.moveTo(PX[i], PY[i]); ctx.lineTo(X[i], Y[i]);
      }
      ctx.stroke();
    }
  }
  function tick(dt) {
    fillBg(0.015 + (1 - opts.trail) * 0.2);
    step(dt); draw();
  }
  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;
    tick(dt);
    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() {
    fillBg(1);
    var keep = opts.trail; opts.trail = 0.9;
    for (var k = 0; k < 260; k++) { t += 0.016; tick(0.016); }
    opts.trail = keep;
  }
  function mv(e) { var r = canvas.getBoundingClientRect(); mx = e.clientX - r.left; my = e.clientY - r.top; hasPtr = true; }
  function lv() { hasPtr = false; mx = my = -9999; }
  var prevTh = -1;
  function update(next) {
    opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", threads: 110, trail: 0.8, speed: 1, turbulence: 1.2, pieces: "on" }, opts, next || {});
    opts.threads = Math.max(20, Math.min(200, Math.round(opts.threads)));
    opts.trail = Math.max(0, Math.min(1, +opts.trail));
    bg = rgb(opts.background, "0c1e18"); acc = rgb(opts.accent, "e6ff55");
    wrap.style.background = opts.background;
    wrap.classList.toggle("no-pieces", String(opts.pieces) === "off");
    populate();
    if (reduce) still(); else { if (prevTh !== opts.threads) fillBg(1); start(); }
    prevTh = opts.threads;
  }
  opts = { background: "#0c1e18", accent: "#e6ff55", threads: 110, trail: 0.8, speed: 1, turbulence: 1.2, pieces: "on" };
  size();
  update(o);
  canvas.addEventListener("pointermove", mv);
  canvas.addEventListener("pointerleave", lv);
  if (typeof ResizeObserver !== "undefined") { ro = new ResizeObserver(function () { size(); if (reduce) still(); }); 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);
      if (ro) ro.disconnect(); if (io) io.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","threads":110,"trail":0.8,"speed":1});

How it works

Every thread is a point steered by a smooth value-noise field that slowly shifts with time. Half the threads follow the field (warp) and half cross it at a right angle (weft), which is what makes the paths weave over each other. Each frame the canvas is veiled with a low-alpha fill of the background colour so old segments fade into trails, then each thread draws one short segment; segments are batched into four paths, one per palette colour. Threads respawn at an edge when they leave or age out. The pointer pushes nearby threads away.

Accessibility and performance

Decorative canvas with a text label; pieces are hidden from assistive tech. Reduced motion pre-runs the threads off screen and shows a single still frame, with no animation. Trails are low contrast by design, so overlaid text needs its own backing.

Four stroked paths per frame regardless of thread count; the cost is the noise lookups, about two per thread. The loop pauses off screen and the pixel ratio is capped at 2. Because trails come from a translucent fill, a very long trail setting can leave faint ghosting on some displays.

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 byThreads (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

Why do the trails never fully clear?

Trails fade with a translucent fill, so 8-bit rounding leaves a faint residue at the longest setting. Shorten the trail or redraw on resize to clear it.

Can I get a transparent background?

Not with this fade technique; it needs to repaint the background colour. Set the background parameter to match your page.

How is it seeded?

A fixed seed, so every load has the same starting layout, which keeps screenshots stable.