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 "Hypnotic spiral" for my site using the Enrichr Library element at https://enrichr.dev/elements/hypnotic-spiral.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/hypnotic-spiral.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:
- speed: 0.5
- density: 8
- fisheye: 0.2
- fineDisp: 0.02
- warpAmp: 0.1
- warpFreq: 5
- centerX: 0
- colorA: #e6ff55
- colorB: #0c1e18
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/hypnotic-spiral.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-spiral"><canvas class="ef-spiral__c" role="img" aria-label="A two-colour spiral that turns slowly, with a fisheye bulge, a fine ripple and a gentle wobble along its arms"></canvas></div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-spiral { position: relative; width: 100%; min-height: 22rem; overflow: hidden; border-radius: 1rem; background: var(--ef-spiral-b, #0c1e18) repeating-radial-gradient(circle at 50% 50%, var(--ef-spiral-a, #e6ff55) 0 2.4rem, var(--ef-spiral-b, #0c1e18) 2.4rem 4.8rem); }
.ef-spiral .ef-spiral__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }JavaScript
// Enrichr Library · https://enrichr.dev
// Hypnotic spiral: a hard-edged two-colour spiral that turns on the spot, drawn by one fullscreen WebGL1 fragment shader.
// Each pixel is measured from the centre (moved sideways by the centre offset), pushed outwards by a fisheye bulge that
// grows with the square of the distance, then turned into an angle and a radius. The spiral is the sine of angle plus
// radius times density, minus time; a wobble along the arms (sine of the angle times the warp frequency) and a fine
// ripple (sine of fifty times the radius) are added before the sign of the sine picks the colour, so the edges stay sharp.
// The clock steps by speed x 0.05 every frame, scaled to 60 frames a second, exactly as in the original pen.
// Reduced motion draws one still frame. The loop pauses off screen and in hidden tabs. Without WebGL a CSS ring pattern
// stays in place.
// mount(root, { speed (turn rate, negative reverses), density (arm tightness), fisheye (bulge), fineDisp (ripple),
// warpAmp (arm wobble), warpFreq (wobbles per turn), centerX (centre offset), colorA (arms), colorB (gaps) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-spiral");
var canvas = root.querySelector(".ef-spiral__c");
var opts = {}, raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0;
var gl = null, prog = null, vb = null, u = {}, ok = false, t = 0, PERIOD = Math.PI * 4;
var DEF = { speed: 0.5, density: 8, fisheye: 0.2, fineDisp: 0.02, warpAmp: 0.1, warpFreq: 5, centerX: 0, colorA: "#e6ff55", colorB: "#0c1e18" };
var VS = "attribute vec2 p;void main(){gl_Position=vec4(p,0.0,1.0);}";
// the original pen's fragment shader, unchanged except that the two colours are uniforms
var FS = "#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\n" +
"uniform float time;uniform float density;uniform float fisheye;uniform float fineDisp;uniform float warpAmp;" +
"uniform float warpFreq;uniform float centerX;uniform vec2 resolution;uniform vec3 colorA;uniform vec3 colorB;" +
"void main(){vec2 uv=(gl_FragCoord.xy-0.5*resolution.xy)/min(resolution.y,resolution.x);" +
"uv.x-=centerX;float dist=length(uv);uv*=1.0+(dist*dist*fisheye);" +
"float r=length(uv);float a=atan(uv.y,uv.x);" +
"float spiral=a+(r*density*2.0)-(time*1.5);" +
"float warp=sin(a*warpFreq+time)*warpAmp;" +
"float ripple=sin(r*50.0+time*2.0)*fineDisp;" +
"float bands=sin(spiral+warp+ripple);" +
"vec3 color=bands>0.0?colorA:colorB;gl_FragColor=vec4(color,1.0);}";
function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
function rgb(h, d) { var m = /^#?([0-9a-f]{6})$/i.exec(h || ""); var v = parseInt(m ? m[1] : d, 16); return [(v >> 16) / 255, ((v >> 8) & 255) / 255, (v & 255) / 255]; }
function sh(type, src) { var s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); return gl.getShaderParameter(s, gl.COMPILE_STATUS) ? s : null; }
function setup() {
var v = sh(gl.VERTEX_SHADER, VS), f = sh(gl.FRAGMENT_SHADER, FS);
if (!v || !f) return false;
prog = gl.createProgram(); gl.attachShader(prog, v); gl.attachShader(prog, f); gl.linkProgram(prog);
gl.deleteShader(v); gl.deleteShader(f);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return false;
gl.useProgram(prog);
vb = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vb);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
var loc = gl.getAttribLocation(prog, "p"); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
["time", "density", "fisheye", "fineDisp", "warpAmp", "warpFreq", "centerX", "resolution", "colorA", "colorB"].forEach(function (k) { u[k] = gl.getUniformLocation(prog, k); });
return true;
}
function size() {
var dpr = Math.min(window.devicePixelRatio || 1, 2);
var w = Math.max(1, Math.round(wrap.clientWidth * dpr)), h = Math.max(1, Math.round(wrap.clientHeight * dpr));
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; gl.viewport(0, 0, w, h); }
}
function draw() {
if (!ok || destroyed || gl.isContextLost()) return;
size();
// every term repeats after 4 pi of time, so wrapping the clock changes nothing on screen and keeps precision
gl.uniform1f(u.time, t % PERIOD);
gl.uniform1f(u.density, opts.density);
gl.uniform1f(u.fisheye, opts.fisheye);
gl.uniform1f(u.fineDisp, opts.fineDisp);
gl.uniform1f(u.warpAmp, opts.warpAmp);
gl.uniform1f(u.warpFreq, opts.warpFreq);
gl.uniform1f(u.centerX, opts.centerX);
gl.uniform2f(u.resolution, canvas.width, canvas.height);
gl.uniform3fv(u.colorA, rgb(opts.colorA, "e6ff55"));
gl.uniform3fv(u.colorB, rgb(opts.colorB, "0c1e18"));
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
function still() { t = 0.5 * 0.05 * 120; draw(); }
function loop(now) {
raf = requestAnimationFrame(loop);
var dt = Math.min(0.1, Math.max(0, (now - last) / 1000)); last = now;
if (!visible || document.hidden) return;
t = (t + opts.speed * 0.05 * dt * 60) % PERIOD;
draw();
}
function update(partial) {
opts = Object.assign({}, DEF, opts, partial || {});
["speed", "density", "fisheye", "fineDisp", "warpAmp", "warpFreq", "centerX"].forEach(function (k) { opts[k] = num(opts[k], DEF[k]); });
wrap.style.setProperty("--ef-spiral-a", opts.colorA);
wrap.style.setProperty("--ef-spiral-b", opts.colorB);
if (destroyed) return;
if (reduce) still(); else draw();
}
function onLost(e) { e.preventDefault(); canvas.style.display = "none"; }
try { gl = canvas.getContext("webgl", { antialias: false, alpha: false, powerPreference: "low-power" }); } catch (e) { gl = null; }
ok = !!gl && setup();
if (!ok) canvas.style.display = "none";
else canvas.addEventListener("webglcontextlost", onLost);
update(o);
if (ok) {
if (!reduce) {
if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(wrap); }
last = performance.now(); raf = requestAnimationFrame(loop);
}
if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed) draw(); }); ro.observe(wrap); }
}
return {
update: update,
destroy: function () {
destroyed = true; cancelAnimationFrame(raf);
if (io) io.disconnect();
if (ro) ro.disconnect();
canvas.removeEventListener("webglcontextlost", onLost);
if (gl) {
var ext = gl.getExtension("WEBGL_lose_context");
if (vb) gl.deleteBuffer(vb); if (prog) gl.deleteProgram(prog);
if (ext) ext.loseContext();
gl = null;
}
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"speed":0.5,"density":8,"fisheye":0.2,"fineDisp":0.02,"warpAmp":0.1,"warpFreq":5,"centerX":0,"colorA":"#e6ff55","colorB":"#0c1e18"});How it works
One fragment shader paints the whole box. Each pixel is measured from the centre in units of the shorter side, moved sideways by the centre offset, then pushed outwards by one plus the fisheye amount times its distance squared, so the edges bulge. That point becomes an angle and a radius. The spiral is the angle plus the radius times twice the density, minus one and a half times the clock. A wobble along the arms (the sine of the angle times the warp frequency, plus the clock, times the warp amplitude) and a fine ripple (the sine of fifty times the radius plus twice the clock, times the ripple amount) are added, and the sign of the sine of the total picks one colour or the other, so the edges stay sharp. The clock steps by the speed times 0.05 every frame, scaled to 60 frames a second, and is wrapped every 4 pi, where all three terms repeat.
Accessibility and performance
The canvas is decorative and has a text label. A turning high-contrast spiral can be uncomfortable for people sensitive to motion, so with prefers-reduced-motion it draws one still frame and never turns. Keep the speed low on pages people read. Nothing here needs keyboard input.
One full-screen triangle and a short fragment shader (two lengths, one arctangent, three sines) per pixel. The pixel ratio is capped at 2, the loop pauses off screen and in hidden tabs, and destroy() frees the buffer, the program and the WebGL context. Without WebGL a still CSS ring pattern stays in place.
| Section | Background |
|---|---|
| Techniques | WebGL |
| Stack | Vanilla JS, WebGL |
| 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 | Hypnotic Spiral Lab by Enrichr on CodePen. 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
Can I make it spin the other way?
Yes. Set a negative rotation speed. Zero stops it on the current frame.
Why are the edges sharp, not smooth?
The original pen colours each pixel by the sign of a sine, which gives a hard edge, and that is kept. The pixel ratio goes up to 2, so the edges stay crisp on sharp screens.
How do I move the centre?
The centre offset slides it sideways by up to half of the shorter side, as in the original. To move it up or down, subtract a value from uv.y next to uv.x in the shader.
Can I use it behind text?
Yes, but it is busy. Put the text on a solid panel, or lower the density and the speed so the spiral calms down.