Copied!
Animated SVG backgrounds

Triangle tile drift

A pattern of two-tone triangle tiles that slides slowly on the diagonal while its colours drift and a soft light pulses in the middle.

← Library

Element · Vanilla JS + SVGUpdated November 6, 2026

SVG backgroundsElementVanilla JS + SVG

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 "Triangle tile drift" for my site using the Enrichr Library element at https://enrichr.dev/elements/triangle-tile-drift.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/triangle-tile-drift.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:
- tile: 100
- slide: 9
- cycle: drift
- cycleTime: 14
- pulse: 6
- glow: 0.25
- c1: #e6ff55
- c2: #1a7a4c
- c3: #4b1a9a
- c4: #fbf8f1
- 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/triangle-tile-drift.json.

Code

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

HTML

<div class="ef-ttd">
  <svg class="ef-ttd__svg" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid slice" role="img" aria-label="A pattern of two-tone triangle tiles in lime, green, violet and cream that slides slowly toward the lower right while a soft light pulses in the middle">
    <defs>
      <linearGradient class="ef-ttd__bggrad" id="ef-ttd-bg" x1="0%" y1="0%" x2="100%" y2="100%">
        <stop class="ef-ttd__bg1" offset="0%" stop-color="#0c1e18"/>
        <stop class="ef-ttd__bg2" offset="100%" stop-color="#050d0a"/>
      </linearGradient>
      <pattern class="ef-ttd__pat" id="ef-ttd-grid" width="100" height="100" patternUnits="userSpaceOnUse">
        <polygon class="ef-ttd__t" points="0,0 50,0 50,50" fill="#b4c94a"/>
        <polygon class="ef-ttd__t" points="0,0 50,50 0,50" fill="#e6ff55"/>
        <polygon class="ef-ttd__t" points="50,0 100,0 50,50" fill="#1a7a4c"/>
        <polygon class="ef-ttd__t" points="100,0 100,50 50,50" fill="#175f3e"/>
        <polygon class="ef-ttd__t" points="0,50 50,50 0,100" fill="#3b1979"/>
        <polygon class="ef-ttd__t" points="50,50 50,100 0,100" fill="#4b1a9a"/>
        <polygon class="ef-ttd__t" points="50,50 100,50 100,100" fill="#c7c8c0"/>
        <polygon class="ef-ttd__t" points="50,50 100,100 50,100" fill="#fbf8f1"/>
      </pattern>
      <radialGradient class="ef-ttd__glowgrad" id="ef-ttd-glow" cx="50%" cy="50%" r="75%">
        <stop class="ef-ttd__g1" offset="0%" stop-color="#fbf8f1" stop-opacity="0.25"/>
        <stop class="ef-ttd__g2" offset="100%" stop-color="#fbf8f1" stop-opacity="0"/>
      </radialGradient>
    </defs>
    <rect class="ef-ttd__base" x="0" y="0" width="100%" height="100%" fill="url(#ef-ttd-bg)"/>
    <rect class="ef-ttd__tiles" x="-100" y="-100" width="1000" height="800" fill="url(#ef-ttd-grid)"/>
    <rect class="ef-ttd__glow" x="0" y="0" width="100%" height="100%" fill="url(#ef-ttd-glow)" opacity="0.55"/>
  </svg>
</div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-ttd { position: relative; width: 100%; min-height: 22rem; overflow: hidden; border-radius: 1rem; background: #0c1e18; }
.ef-ttd .ef-ttd__svg { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Triangle tile drift: an SVG pattern tile split into four squares, each cut on a diagonal into a light and a dark triangle.
// Three loops run at once, as in the original SVG: the pattern slides one whole tile toward the lower right every 9 s in a
// straight line (so the loop has no seam); the tile colours shift hue on a 14 s linear cycle with saturation rising to 1.15
// at the half-way point; and a soft radial light in the middle pulses between 35% and 75% opacity every 6 s on an
// ease-in-out curve. The hue shift uses the same matrices as the CSS hue-rotate() and saturate() filters, applied to the
// eight fill colours, so there is no filter to repaint. "Gentle drift" swings the hue 40 degrees each way so the colours stay
// close to the ones you pick; "Full hue cycle" is the original 360 degree turn.
// Reduced motion shows one still frame with the colours as picked. Pauses off screen and in hidden tabs.
// mount(root, { tile (px), slide (s per tile), cycle ("drift" | "full" | "off"), cycleTime (s), pulse (s), glow (strength),
//               c1, c2, c3, c4 (top left, top right, bottom left, bottom right squares), background })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var box = root.querySelector(".ef-ttd");
  var svg = root.querySelector(".ef-ttd__svg");
  var pat = root.querySelector(".ef-ttd__pat");
  var tris = [].slice.call(root.querySelectorAll(".ef-ttd__t"));
  var tiles = root.querySelector(".ef-ttd__tiles");
  var glow = root.querySelector(".ef-ttd__glow");
  var g1 = root.querySelector(".ef-ttd__g1");
  var bg1 = root.querySelector(".ef-ttd__bg1"), bg2 = root.querySelector(".ef-ttd__bg2");
  var INK = [12, 30, 24];
  // which square each triangle belongs to, and whether it is the darker facet (same light/dark split as the original)
  var SQUARE = [0, 0, 1, 1, 2, 2, 3, 3], DARK = [1, 0, 0, 1, 1, 0, 1, 0];
  var opts = {}, base = [], raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0, clock = 0, W = 1, H = 1;

  // unique ids so several copies can sit on one page
  var uid = Math.random().toString(36).slice(2, 9);
  [["ef-ttd__bggrad", "ef-ttd__base"], ["ef-ttd__pat", "ef-ttd__tiles"], ["ef-ttd__glowgrad", "ef-ttd__glow"]].forEach(function (pair, i) {
    var def = root.querySelector("." + pair[0]), id = "ef-ttd-" + i + "-" + uid;
    def.setAttribute("id", id);
    root.querySelector("." + pair[1]).setAttribute("fill", "url(#" + id + ")");
  });

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function hex(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 clamp(v) { return v < 0 ? 0 : v > 255 ? 255 : v; }
  function rgb(c) { return "rgb(" + Math.round(c[0]) + "," + Math.round(c[1]) + "," + Math.round(c[2]) + ")"; }
  // CSS ease-in-out, cubic-bezier(0.42, 0, 0.58, 1)
  function bez(a, b, t) { var u = 1 - t; return 3 * u * u * t * a + 3 * u * t * t * b + t * t * t; }
  function easeInOut(x) {
    var lo = 0, hi = 1, t = x;
    for (var i = 0; i < 20; i++) { var v = bez(0.42, 0.58, t); if (Math.abs(v - x) < 1e-5) break; if (v < x) lo = t; else hi = t; t = (lo + hi) / 2; }
    return bez(0, 1, t);
  }
  // hue-rotate(deg) then saturate(s), the Filter Effects matrices, clamped after each step like a filter chain
  function filter(c, deg, s) {
    var a = deg * Math.PI / 180, cs = Math.cos(a), sn = Math.sin(a), r = c[0], g = c[1], b = c[2];
    var r1 = clamp(r * (0.213 + cs * 0.787 - sn * 0.213) + g * (0.715 - cs * 0.715 - sn * 0.715) + b * (0.072 - cs * 0.072 + sn * 0.928));
    var g1 = clamp(r * (0.213 - cs * 0.213 + sn * 0.143) + g * (0.715 + cs * 0.285 + sn * 0.140) + b * (0.072 - cs * 0.072 - sn * 0.283));
    var b1 = clamp(r * (0.213 - cs * 0.213 - sn * 0.787) + g * (0.715 - cs * 0.715 + sn * 0.715) + b * (0.072 + cs * 0.928 + sn * 0.072));
    return [
      clamp(r1 * (0.213 + 0.787 * s) + g1 * (0.715 - 0.715 * s) + b1 * (0.072 - 0.072 * s)),
      clamp(r1 * (0.213 - 0.213 * s) + g1 * (0.715 + 0.285 * s) + b1 * (0.072 - 0.072 * s)),
      clamp(r1 * (0.213 - 0.213 * s) + g1 * (0.715 - 0.715 * s) + b1 * (0.072 + 0.928 * s))
    ];
  }

  function frame(t) {
    // slide: one tile toward the lower right per "slide" seconds, linear
    var p = (t / opts.slide) % 1, d = p * opts.tile;
    pat.setAttribute("patternTransform", "translate(" + d.toFixed(2) + " " + d.toFixed(2) + ") scale(" + (opts.tile / 100).toFixed(4) + ")");
    // colour: 0 -> 50% -> 100% keyframes, linear; saturation 1 -> 1.15 -> 1
    var c = (t / opts.cycleTime) % 1, sat = c < 0.5 ? 1 + 0.15 * c / 0.5 : 1.15 - 0.15 * (c - 0.5) / 0.5, hue = 0;
    if (opts.cycle === "full") hue = 360 * c;
    else if (opts.cycle === "drift") hue = 40 * (c < 0.25 ? c * 4 : c < 0.75 ? 2 - c * 4 : c * 4 - 4);
    else sat = 1;
    for (var i = 0; i < tris.length; i++) tris[i].setAttribute("fill", rgb(hue || sat !== 1 ? filter(base[i], hue, sat) : base[i]));
    // glow: 0.35 -> 0.75 -> 0.35 with ease-in-out on each half
    var g = (t / opts.pulse) % 1, op = g < 0.5 ? 0.35 + 0.4 * easeInOut(g / 0.5) : 0.75 - 0.4 * easeInOut((g - 0.5) / 0.5);
    glow.setAttribute("opacity", op.toFixed(3));
  }
  function still() {
    var keep = opts.cycle; opts.cycle = "off";
    frame(opts.pulse * 0.25); opts.cycle = keep;
  }

  function loop(now) {
    raf = requestAnimationFrame(loop);
    var dt = Math.min(0.05, (now - last) / 1000 || 0); last = now;
    if (!visible || document.hidden) return;
    clock += dt;
    frame(clock);
  }
  function resize() {
    W = Math.max(1, box.clientWidth); H = Math.max(1, box.clientHeight);
    svg.setAttribute("viewBox", "0 0 " + W + " " + H);
    svg.setAttribute("preserveAspectRatio", "none");
    tiles.setAttribute("x", -opts.tile); tiles.setAttribute("y", -opts.tile);
    tiles.setAttribute("width", W + opts.tile * 2); tiles.setAttribute("height", H + opts.tile * 2);
    if (reduce) still(); else frame(clock);
  }

  function update(partial) {
    opts = Object.assign({ tile: 100, slide: 9, cycle: "drift", cycleTime: 14, pulse: 6, glow: 0.25,
      c1: "#e6ff55", c2: "#1a7a4c", c3: "#4b1a9a", c4: "#fbf8f1", background: "#0c1e18" }, opts, partial || {});
    opts.tile = Math.max(10, num(opts.tile, 100)); opts.slide = Math.max(0.2, num(opts.slide, 9));
    opts.cycleTime = Math.max(0.5, num(opts.cycleTime, 14)); opts.pulse = Math.max(0.2, num(opts.pulse, 6));
    opts.glow = Math.max(0, num(opts.glow, 0.25));
    if (["drift", "full", "off"].indexOf(opts.cycle) < 0) opts.cycle = "drift";
    var sq = [hex(opts.c1, "e6ff55"), hex(opts.c2, "1a7a4c"), hex(opts.c3, "4b1a9a"), hex(opts.c4, "fbf8f1")];
    base = SQUARE.map(function (s, i) {
      var c = sq[s];
      return DARK[i] ? [c[0] * 0.78 + INK[0] * 0.22, c[1] * 0.78 + INK[1] * 0.22, c[2] * 0.78 + INK[2] * 0.22] : c;
    });
    var bg = hex(opts.background, "0c1e18");
    bg1.setAttribute("stop-color", opts.background);
    bg2.setAttribute("stop-color", rgb([bg[0] * 0.45, bg[1] * 0.45, bg[2] * 0.45]));
    box.style.background = opts.background;
    g1.setAttribute("stop-opacity", opts.glow);
    if (destroyed) return;
    resize();
  }

  update(o);
  if (!reduce) {
    if ("IntersectionObserver" in window) { io = new IntersectionObserver(function (e) { visible = e[0].isIntersecting; }); io.observe(box); }
    last = performance.now(); raf = requestAnimationFrame(loop);
  }
  if ("ResizeObserver" in window) { ro = new ResizeObserver(function () { if (!destroyed) resize(); }); ro.observe(box); }
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf);
      if (io) io.disconnect();
      if (ro) ro.disconnect();
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"tile":100,"slide":9,"cycle":"drift","cycleTime":14,"pulse":6,"glow":0.25,"c1":"#e6ff55","c2":"#1a7a4c","c3":"#4b1a9a","c4":"#fbf8f1","background":"#0c1e18"});

How it works

One SVG pattern tile is split into four squares, and each square is cut on a diagonal into a light and a darker triangle (the darker one is the square's colour mixed 22% toward ink). Three loops run at once, with the original's timings. The pattern slides one whole tile toward the lower right every 9 seconds in a straight line, so the loop has no seam. The colours shift on a 14 second linear cycle, with saturation rising to 1.15 at the half-way point and back: the shift uses the same maths as the CSS hue-rotate() and saturate() filters, applied to the eight fill colours. Full hue cycle turns all the way round the colour wheel as the original does; Gentle drift swings 40 degrees each way so the colours stay close to the ones you pick. A soft radial light in the middle pulses between 35% and 75% opacity every 6 seconds on an ease-in-out curve.

Accessibility and performance

The SVG is decorative and has a text label. With prefers-reduced-motion it shows one still frame in the colours you picked and never animates. The changes are slow and nothing flashes. Nothing needs the keyboard.

There is no filter: the colour shift is worked out for eight colours in script and written to the triangles, and the slide is one transform on the pattern. The browser redraws from one small tile. The loop pauses off screen or in a hidden tab.

SectionAnimated SVG backgrounds
StackVanilla JS, SVG
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired bySliding triangle tile pattern 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.

Marquee tickerEndless horizontal text that pauses on hover, with speed, gap and direction controls. Split text revealA headline whose words rise out of a mask one after another when it scrolls into view. Letter pop introEach letter overshoots its size and settles, staggered left to right, for a bold page intro.

FAQ

Can I get the original rainbow colour cycle?

Yes. Set Colour shift to Full hue cycle. Every colour then turns all the way round the colour wheel every 14 seconds, as in the original.

Why not use a CSS filter for the colour shift?

A hue-rotate filter on a full-screen pattern makes the browser repaint the whole area through the filter every frame. Applying the same filter maths to eight colours in script looks the same and costs almost nothing.

Can I put content on top?

Yes. It is a background: give .ef-ttd a height and place your heading over it with position: absolute or a grid that stacks both in one cell. The pattern takes no pointer input.

How do I slow it down?

Raise Slide time per tile, Colour shift time and Glow pulse time. The original values are 9, 14 and 6 seconds.