Copied!
Background

Hazard stripe warp

Bold two-colour hazard stripes that slide sideways while the whole surface bends in a slow wave and a quicker sideways turbulence.

← Library

Element · Vanilla JS + WebGLUpdated November 6, 2026

3DElementVanilla JS + WebGL

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 "Hazard stripe warp" for my site using the Enrichr Library element at https://enrichr.dev/elements/hazard-stripe-warp.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/hazard-stripe-warp.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:
- flowSpeed: 0.0003
- waveSpeed: 0.002
- waveIntensity: 0.05
- turbIntensity: 0.05
- turbSpeed: 0.02
- stripeAngle: 0.78
- stripeScale: 2
- stripe: #e6ff55
- background: #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/hazard-stripe-warp.json.

Code

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

HTML

<div class="ef-hzw"><canvas class="ef-hzw__c" role="img" aria-label="Bold lime and dark green hazard stripes that slide sideways while the whole surface bends in a slow wave"></canvas></div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-hzw { position: relative; width: 100%; min-height: 22rem; overflow: hidden; border-radius: 1rem; background: var(--ef-hzw-b, #0c1e18) repeating-linear-gradient(180deg, var(--ef-hzw-a, #e6ff55) 0 17.7%, var(--ef-hzw-b, #0c1e18) 17.7% 35.4%); }
.ef-hzw .ef-hzw__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Hazard stripe warp: bold two-colour stripes that slide sideways while the whole surface bends in a slow sine wave and a
// quicker sideways turbulence. Raw WebGL1, no libraries: a 128 x 128 segment plane (indexed triangles, built here, 8 units
// wide in clip space so only its middle shows) whose vertices are moved in the vertex shader, and a fragment shader that
// rotates the plane's texture coordinates and draws hard-edged stripes from them.
// Three clocks drive it, as in the original pen: the stripe flow and the wave each step forward by their own speed every
// frame, and the turbulence clock steps by 0.01 a frame and is multiplied by the turbulence speed. Steps are scaled to
// 60 frames a second, so the motion is the same on fast and slow screens.
// Reduced motion draws one still frame (the state after three seconds). The loop pauses off screen and in hidden tabs.
// Without WebGL the CSS stripe gradient stays.
// mount(root, { flowSpeed (stripe slide per frame), waveSpeed (wave clock per frame), waveIntensity (wave height),
//               turbIntensity (turbulence width), turbSpeed (turbulence rate), stripeAngle (radians),
//               stripeScale (stripe count, low = bolder), stripe (stripe colour), background (gap colour) })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-hzw");
  var canvas = root.querySelector(".ef-hzw__c");
  var opts = {}, raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0;
  var gl = null, prog = null, vb = null, ib = null, u = {}, nIdx = 0, ok = false;
  var flowTime = 0, waveTime = 0, turbTime = 0, SEG = 128, SIZE = 8, TAU = Math.PI * 2;
  var DEF = { flowSpeed: 0.0003, waveSpeed: 0.002, waveIntensity: 0.05, turbIntensity: 0.05, turbSpeed: 0.02, stripeAngle: 0.78, stripeScale: 2, stripe: "#e6ff55", background: "#0c1e18" };

  // the original pen's two shaders, unchanged except that the colours are uniforms
  var VS = "attribute vec2 aPos;attribute vec2 aUv;varying vec2 vUv;" +
    "uniform float uWaveTime;uniform float uTurbTime;uniform float uWaveIntensity;uniform float uTurbIntensity;uniform float uTurbSpeed;" +
    "void main(){vUv=aUv;vec3 pos=vec3(aPos,0.0);" +
    "pos.y+=sin(pos.x*2.0+uWaveTime*5.0)*uWaveIntensity;" +
    "pos.x+=sin(pos.y*5.0+uTurbTime*uTurbSpeed*10.0)*uTurbIntensity;" +
    "gl_Position=vec4(pos,1.0);}";
  var FS = "#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\n" +
    "uniform float uFlowTime;uniform float uStripeAngle;uniform float uStripeScale;uniform vec3 uA;uniform vec3 uB;varying vec2 vUv;" +
    "void main(){float c=cos(uStripeAngle);float s=sin(uStripeAngle);mat2 rot=mat2(c,-s,s,c);" +
    "vec2 uv=(vUv-0.5)*4.0;uv=rot*uv;uv.x+=uFlowTime;" +
    "float stripe=fract((uv.x+uv.y)*uStripeScale);" +
    "vec3 color=stripe>0.5?uA:uB;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);
    // the plane, laid out like a three.js PlaneGeometry(8, 8, 128, 128): rows from top to bottom, uv.y = 1 at the top
    var n = SEG + 1, data = new Float32Array(n * n * 4), idx = new Uint16Array(SEG * SEG * 6), k = 0, ix, iy;
    for (iy = 0; iy < n; iy++) for (ix = 0; ix < n; ix++) {
      data[k++] = ix / SEG * SIZE - SIZE / 2; data[k++] = SIZE / 2 - iy / SEG * SIZE;
      data[k++] = ix / SEG; data[k++] = 1 - iy / SEG;
    }
    k = 0;
    for (iy = 0; iy < SEG; iy++) for (ix = 0; ix < SEG; ix++) {
      var a = ix + n * iy, b = ix + n * (iy + 1), c = ix + 1 + n * (iy + 1), d = ix + 1 + n * iy;
      idx[k++] = a; idx[k++] = b; idx[k++] = d; idx[k++] = b; idx[k++] = c; idx[k++] = d;
    }
    nIdx = k;
    vb = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, vb); gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
    ib = gl.createBuffer(); gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ib); gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, idx, gl.STATIC_DRAW);
    var lp = gl.getAttribLocation(prog, "aPos"), lu = gl.getAttribLocation(prog, "aUv");
    gl.enableVertexAttribArray(lp); gl.vertexAttribPointer(lp, 2, gl.FLOAT, false, 16, 0);
    gl.enableVertexAttribArray(lu); gl.vertexAttribPointer(lu, 2, gl.FLOAT, false, 16, 8);
    ["uWaveTime", "uTurbTime", "uWaveIntensity", "uTurbIntensity", "uTurbSpeed", "uFlowTime", "uStripeAngle", "uStripeScale", "uA", "uB"].forEach(function (key) { u[key] = gl.getUniformLocation(prog, key); });
    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); }
  }
  // the clocks are wrapped by their own period before they reach the GPU: the picture is identical, and float
  // precision stays good however long the page is open
  function wrapT(t, period) { return period > 0 && isFinite(period) ? t % period : t; }
  function draw() {
    if (!ok || destroyed || gl.isContextLost()) return;
    size();
    gl.uniform1f(u.uFlowTime, wrapT(flowTime, 1 / opts.stripeScale));
    gl.uniform1f(u.uWaveTime, wrapT(waveTime, TAU / 5));
    gl.uniform1f(u.uTurbTime, wrapT(turbTime, opts.turbSpeed > 0 ? TAU / (opts.turbSpeed * 10) : 0));
    gl.uniform1f(u.uTurbSpeed, opts.turbSpeed);
    gl.uniform1f(u.uWaveIntensity, opts.waveIntensity);
    gl.uniform1f(u.uTurbIntensity, opts.turbIntensity);
    gl.uniform1f(u.uStripeAngle, opts.stripeAngle);
    gl.uniform1f(u.uStripeScale, opts.stripeScale);
    gl.uniform3fv(u.uA, rgb(opts.stripe, "e6ff55"));
    gl.uniform3fv(u.uB, rgb(opts.background, "0c1e18"));
    gl.drawElements(gl.TRIANGLES, nIdx, gl.UNSIGNED_SHORT, 0);
  }
  function advance(frames) {
    flowTime += opts.flowSpeed * frames;
    waveTime += opts.waveSpeed * frames;
    turbTime += 0.01 * frames;
  }
  function still() { flowTime = 0; waveTime = 0; turbTime = 0; advance(180); 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;
    advance(dt * 60);
    draw();
  }

  function update(partial) {
    opts = Object.assign({}, DEF, opts, partial || {});
    opts.flowSpeed = num(opts.flowSpeed, DEF.flowSpeed); opts.waveSpeed = num(opts.waveSpeed, DEF.waveSpeed);
    opts.waveIntensity = num(opts.waveIntensity, DEF.waveIntensity); opts.turbIntensity = num(opts.turbIntensity, DEF.turbIntensity);
    opts.turbSpeed = num(opts.turbSpeed, DEF.turbSpeed); opts.stripeAngle = num(opts.stripeAngle, DEF.stripeAngle);
    opts.stripeScale = Math.max(0.01, num(opts.stripeScale, DEF.stripeScale));
    wrap.style.setProperty("--ef-hzw-a", opts.stripe);
    wrap.style.setProperty("--ef-hzw-b", opts.background);
    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 (ib) gl.deleteBuffer(ib); if (prog) gl.deleteProgram(prog);
        if (ext) ext.loseContext();
        gl = null;
      }
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"flowSpeed":0.0003,"waveSpeed":0.002,"waveIntensity":0.05,"turbIntensity":0.05,"turbSpeed":0.02,"stripeAngle":0.78,"stripeScale":2,"stripe":"#e6ff55","background":"#0c1e18"});

How it works

A flat grid of 128 by 128 squares (32,768 triangles, built in code) is stretched to eight times the size of the screen, so only its middle shows. The vertex shader moves every point twice: up and down by a sine of its x position plus the wave clock, then sideways by a sine of its new y position plus the turbulence clock. The fragment shader turns the grid's texture coordinates by the stripe angle, slides them by the flow clock, and colours each pixel by whether the fractional part of x plus y, times the stripe scale, is above or below one half, which gives hard edges. The flow and wave clocks step forward by their own speed every frame and the turbulence clock by 0.01, scaled to 60 frames a second. Before each clock reaches the GPU it is wrapped by its own repeat length, so the picture is the same but the numbers stay small however long the page stays open.

Accessibility and performance

The canvas is decorative and has a text label. With prefers-reduced-motion it draws one still frame (the state after three seconds) and never animates. Nothing flashes: at the default speeds a stripe takes many seconds to cross. Nothing here needs keyboard input.

One draw call a frame: about 16,600 points and 32,800 triangles moved on the GPU, and a fragment shader with one cosine, one sine and a fract per pixel. The pixel ratio is capped at 2, the loop pauses off screen and in hidden tabs, and destroy() frees the buffers and the WebGL context. Without WebGL a CSS repeating gradient shows the same stripes, still.

SectionBackground
TechniquesWebGL
StackVanilla JS, WebGL
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byHazard Stripes Distortion 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.

Shader gradient backgroundA slow, flowing noise gradient drawn with one WebGL fragment shader, with a CSS fallback. Depth map shaderA photo and its depth map drawn by a WebGL shader, so the near parts of the picture move more than the far ones as the pointer moves. Scroll camera landscapeA procedural low-poly mountain range that the camera flies through as you scroll, with a sun that moves from dawn to dusk.

FAQ

Can I put content on top of it?

Yes. Give .ef-hzw a height and place your heading or buttons over it with position: absolute, or stack both in one grid cell. The canvas takes no pointer input. The stripes are loud, so put text on a solid panel.

Why are the stripes horizontal at the default angle?

The stripes follow x plus y after the texture coordinates are turned. At 0.78 radians (about 45 degrees) that sum lines up with y, so the bands run across. Set the angle to 0 for classic diagonal hazard stripes.

Why do the stripes stretch on a wide box?

The plane is drawn straight in clip space, exactly like the original pen, so it fills the box whatever its shape. Lower the stripe scale for bolder bands on very wide boxes.

Does it need three.js?

No. The original used a three.js PlaneGeometry and ShaderMaterial; here the grid and its indices are built by hand and the two shaders are the same maths in plain WebGL1.