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 "Plasma field" for my site using the Enrichr Library element at https://enrichr.dev/elements/plasma-field.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/plasma-field.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
- speed: 1
- scale: 1.2
- cell: 8
- ripple: 0.8
- pixelated: off
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/plasma-field.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-pla">
<div class="ef-pla-fb"></div>
<canvas class="ef-pla-cv" aria-hidden="true"></canvas>
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false"><defs><symbol id="ef-pla-pz1" 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-pla-pz4" 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-pla-pz8" 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>
<svg class="ef-pla-pz ef-pla-pz--a" aria-hidden="true"><use href="#ef-pla-pz1"/></svg>
<svg class="ef-pla-pz ef-pla-pz--b" aria-hidden="true"><use href="#ef-pla-pz4"/></svg>
<svg class="ef-pla-pz ef-pla-pz--c" aria-hidden="true"><use href="#ef-pla-pz8"/></svg>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-pla { position: relative; width: 100%; height: 100%; min-height: 18rem; overflow: hidden; background: #0c1e18; isolation: isolate; }
.ef-pla-fb { position: absolute; inset: 0; background: conic-gradient(from 200deg at 40% 60%, #005236, #0a6b46, #e6ff55, #2f7d5b, #005236); }
.ef-pla-cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; image-rendering: auto; }
.ef-pla.is-pix .ef-pla-cv { image-rendering: pixelated; }
.ef-pla.is-ready .ef-pla-fb { opacity: 0; }
.ef-pla-pz { position: absolute; color: #0c1e18; opacity: 0.35; pointer-events: none; will-change: transform; }
.ef-pla-pz--a { aspect-ratio: 1; width: 24%; right: 9%; bottom: 12%; transform: rotate(12deg); animation: ef-pla-fa 12s ease-in-out infinite; }
.ef-pla-pz--b { aspect-ratio: 503 / 322; width: 38%; left: 7%; top: 10%; color: #fbf8f1; opacity: 0.22; transform: rotate(-8deg); animation: ef-pla-fb 15s ease-in-out infinite; }
.ef-pla-pz--c { aspect-ratio: 322 / 503; width: 11%; left: 52%; top: 14%; color: #fbf8f1; opacity: 0.3; transform: rotate(18deg); animation: ef-pla-fc 9s ease-in-out infinite; }
@keyframes ef-pla-fa { 50% { transform: translateY(-14px) rotate(6deg); } }
@keyframes ef-pla-fb { 50% { transform: translateY(12px) rotate(-3deg); } }
@keyframes ef-pla-fc { 50% { transform: translateY(-9px) rotate(26deg); } }
@media (prefers-reduced-motion: reduce) { .ef-pla-pz { animation: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Plasma field: a classic sum-of-sines colour field computed at low resolution on a 2D canvas and upscaled by CSS, mapped through a brand-green palette.
// The pointer drops ripples that spread and fade. Reduced motion draws one still frame.
// Original rebuild from the general idea of a plasma effect; palette is the Enrichr brand greens, lime and cream only.
// mount(root, { accent, speed (0.1-3), scale (0.5-3), cell (3-14 px per sample), ripple (0-1), pixelated ("on"|"off") })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var box = root.querySelector(".ef-pla");
var cv = root.querySelector(".ef-pla-cv");
var ctx = cv.getContext("2d");
var opts = {}, raf = 0, vis = true, io = null, ro = null, offs = [];
var W = 2, H = 2, img = null, lut = null, tAcc = 0, last = performance.now();
var rip = []; // {x,y,t}
function hex(h) {
var m = /^#?([0-9a-f]{6})$/i.exec(h || "");
var n = parseInt(m ? m[1] : "e6ff55", 16);
return [n >> 16, (n >> 8) & 255, n & 255];
}
function buildLut() {
var a = hex(opts.accent);
var stops = [[0.0, [12, 30, 24]], [0.25, [0, 82, 54]], [0.5, [10, 107, 70]], [0.68, [47, 125, 91]], [0.84, [198, 242, 107]], [0.94, a], [1.0, [251, 248, 241]]];
lut = new Uint8ClampedArray(256 * 3);
for (var i = 0; i < 256; i++) {
var v = i / 255, k = 0;
while (k < stops.length - 2 && v > stops[k + 1][0]) k++;
var s0 = stops[k], s1 = stops[k + 1], f = Math.max(0, Math.min(1, (v - s0[0]) / (s1[0] - s0[0])));
f = f * f * (3 - 2 * f);
for (var c = 0; c < 3; c++) lut[i * 3 + c] = s0[1][c] + (s1[1][c] - s0[1][c]) * f;
}
}
function size() {
var r = box.getBoundingClientRect();
W = Math.max(8, Math.round(r.width / opts.cell)); H = Math.max(8, Math.round(r.height / opts.cell));
if (cv.width !== W || cv.height !== H) { cv.width = W; cv.height = H; img = ctx.createImageData(W, H); }
if (!img) img = ctx.createImageData(W, H);
}
function draw() {
if (!img || !lut) return;
var d = img.data, t = tAcc, s = opts.scale * 6 / Math.max(W, H);
var i, x, y, v, p = 0;
// live ripples in canvas space
var rs = [];
for (i = 0; i < rip.length; i++) { var age = t - rip[i].t; rs.push(rip[i].x * W, rip[i].y * H, age); }
for (y = 0; y < H; y++) {
var fy = y * s;
for (x = 0; x < W; x++) {
var fx = x * s;
v = Math.sin(fx * 1.3 + t) + Math.sin(fy * 1.7 - t * 1.2) + Math.sin((fx + fy) * 0.9 + t * 0.7);
var cx = fx + 2 * Math.sin(t * 0.4), cy = fy + 2 * Math.cos(t * 0.33);
v += Math.sin(Math.sqrt(cx * cx + cy * cy) * 1.4 - t * 1.1);
for (i = 0; i < rs.length; i += 3) {
var dx = x - rs[i], dy = y - rs[i + 1], dist = Math.sqrt(dx * dx + dy * dy), age2 = rs[i + 2];
var wave = dist - age2 * 14;
v += opts.ripple * 2.2 * Math.sin(wave * 0.7) * Math.exp(-wave * wave * 0.02) * Math.exp(-age2 * 0.9);
}
var q = (v / 4 + 1) * 0.5;
var idx = (q < 0 ? 0 : q > 1 ? 255 : (q * 255) | 0) * 3;
d[p] = lut[idx]; d[p + 1] = lut[idx + 1]; d[p + 2] = lut[idx + 2]; d[p + 3] = 255;
p += 4;
}
}
ctx.putImageData(img, 0, 0);
box.classList.add("is-ready");
}
function frame(now) {
raf = 0;
var dt = Math.min(0.05, (now - last) / 1000); last = now;
tAcc += dt * opts.speed;
rip = rip.filter(function (r) { return tAcc - r.t < 4; });
draw();
if (vis && !reduce) raf = requestAnimationFrame(frame);
}
function kick() {
if (reduce) { size(); tAcc = 3.2; draw(); return; }
if (!raf && vis) { last = performance.now(); raf = requestAnimationFrame(frame); }
}
function on(el, ev, fn) { el.addEventListener(ev, fn); offs.push(function () { el.removeEventListener(ev, fn); }); }
var lastDrop = 0;
function drop(e) {
if (reduce) return;
var n = performance.now();
if (e.type === "pointermove" && n - lastDrop < 140) return;
lastDrop = n;
var r = box.getBoundingClientRect();
rip.push({ x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height, t: tAcc });
if (rip.length > 8) rip.shift();
}
on(box, "pointermove", drop);
on(box, "pointerdown", drop);
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { size(); if (reduce) draw(); }); ro.observe(box); }
else on(window, "resize", size);
if ("IntersectionObserver" in window) {
io = new IntersectionObserver(function (en) { vis = en[0].isIntersecting; if (vis) kick(); else if (raf) { cancelAnimationFrame(raf); raf = 0; } });
io.observe(box);
}
function update(next) {
var prev = opts;
opts = Object.assign({ accent: "#e6ff55", speed: 1, scale: 1.2, cell: 8, ripple: 0.8, pixelated: "off" }, opts, next || {});
opts.cell = Math.max(3, Math.min(14, opts.cell));
box.classList.toggle("is-pix", opts.pixelated === "on" || opts.pixelated === true);
if (prev.accent !== opts.accent || !lut) buildLut();
if (prev.cell !== opts.cell || !img) size();
kick();
if (!raf) draw();
}
function destroy() {
if (raf) cancelAnimationFrame(raf); raf = 0; vis = false;
if (io) io.disconnect(); if (ro) ro.disconnect();
offs.forEach(function (f) { f(); }); offs = [];
}
update(o);
return { update: update, destroy: destroy };
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","speed":1,"scale":1.2,"cell":8,"ripple":0.8,"pixelated":"off"});How it works
A canvas is sized to the box divided by the cell size, so it holds only a few thousand samples. Each frame, every sample sums four sine terms (horizontal, vertical, diagonal and radial, with time offsets) and any live ripples, then the result is normalised to 0 to 1 and looked up in a 256-entry palette running dark ink, greens, lime, accent and cream. CSS scales the canvas up with smooth interpolation, which gives the soft look, or with pixelated rendering for chunky cells. Each pointer move adds a ripple: a travelling sine wave under a Gaussian ring that decays with age.
Accessibility and performance
Decorative only: canvas and pieces are aria-hidden and not focusable. Reduced motion draws a single still frame and ignores pointer ripples; the pieces stop floating. The field is mid-green with bright lime peaks, so put text on a scrim or in a solid panel and check contrast.
Cost scales with the number of cells, not the box size, so a larger cell size is the main performance lever. Drawing pauses off-screen via IntersectionObserver and stops on destroy. Ripples are capped at eight and expire after a few seconds.
| Section | Background |
|---|---|
| Techniques | Pointer interaction |
| Stack | Vanilla JS, Canvas 2D, CSS |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Plasma effect by Classic demoscene technique on en.wikipedia.org. 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 is it so blurry?
That is the point of low resolution plus upscaling. Turn on Hard pixels or lower the cell size for a different look.
Can I recolour it?
The accent is a parameter. The other palette stops are in buildLut(); edit them to retheme.
Is it fast on large boxes?
Yes, because the canvas size is the box divided by the cell size. Raise the cell size if a full-screen version feels heavy.