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.
| Section | Background |
|---|---|
| Techniques | Pointer interaction |
| Stack | Vanilla JS, Canvas |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| On CodePen | Open the live pen on our CodePen account; fork it to experiment. |
| Inspired by | Threads (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.
More elements
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.