Copied!
Background

Petal spin matrix

A grid of four-petal stars that spin one after another in staggered waves, with GSAP-style eases, stagger patterns and a pointer halo.

← Library

Element · Vanilla JS + CanvasUpdated November 6, 2026

BackgroundElementVanilla JS + Canvas

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 "Petal spin matrix" for my site using the Enrichr Library element at https://enrichr.dev/elements/petal-spin-matrix.html.

Read the machine-readable spec and source at https://enrichr.dev/elements/petal-spin-matrix.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:
- ease: power2.in
- sequence: diagonal
- degrees: 360
- duration: 1.2
- stagger: 0.03
- rest: 0.5
- tile: 72
- petal: 1
- angle: 0
- mode: solid
- glow: 0
- hover: 1
- palette: enrichr
- primary: #fbf8f1
- secondary: #e6ff55
- background: #0c1e18
- stroke: #085e3d

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/petal-spin-matrix.json.

Code

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

HTML

<div class="ef-psm"><canvas class="ef-psm__c" role="img" aria-label="A grid of four-petal stars that spin one after another in a slow wave sweeping across the pattern"></canvas></div>

CSS

/* Enrichr Library · https://enrichr.dev */
.ef-psm { position: relative; width: 100%; min-height: 24rem; overflow: hidden; border-radius: 1rem; background: #0c1e18; touch-action: pan-y; }
.ef-psm .ef-psm__c { position: absolute; inset: 0; width: 100%; height: 100%; display: block; cursor: crosshair; }

JavaScript

// Enrichr Library · https://enrichr.dev
// Petal spin matrix: a grid of four-petal stars (one vector motif drawn on a canvas) that spin in staggered waves. It
// re-creates a GSAP timeline by hand: every star gets a delay from its place in the grid (diagonal ripple, centre outward,
// rows, columns or random) times the stagger interval times the number of stars, then turns by the spin angle over the spin
// duration on a GSAP-style ease (power1-4.in, back.in, circ.in, expo.in, bounce.in, elastic.in use GSAP's own formulas).
// The timeline repeats after a rest, snapping each star back to its start angle, which looks the same because the star has
// four-fold symmetry. While a star turns its colour slides from the primary toward the accent. Near the pointer stars grow,
// fatten and glow; a click spins the stars around that point at once. Changing a timing setting restarts the wave.
// Reduced motion draws one still frame partway through the first wave and ignores the pointer. The loop pauses off screen
// and in hidden tabs.
// mount(root, { ease, sequence (stagger pattern), degrees (spin per wave), duration (s), stagger (s per star), rest (s),
//               tile (px), petal (petal scale), angle (grid angle, deg), mode (solid, wireframe, glow, checker), glow (px),
//               hover (1 on, 0 off), palette, primary, secondary (accent while spinning), background, stroke })
function mount(root, o) {
  var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  var wrap = root.querySelector(".ef-psm");
  var canvas = root.querySelector(".ef-psm__c");
  var ctx = canvas.getContext("2d");
  // the quad-petal motif from the original pen, drawn in a 480 x 480 box centred on (240, 240)
  var MOTIF = new Path2D("M70.9 69.7 69.7 71a85.2 85.2 0 0 0 43.6 143.8L240 240l-25.4-126.7A85.2 85.2 0 0 0 71 69.7ZM69.7 409.1l1.2 1.2a85.2 85.2 0 0 0 143.8-43.6L240 240l-126.7 25.3a85.2 85.2 0 0 0-43.6 143.8ZM410.3 70.9l-1.2-1.2a85.2 85.2 0 0 0-143.8 43.6L240 240l126.7-25.4A85.2 85.2 0 0 0 410.3 71ZM366.7 265.4 240 240l25.4 126.7A85.2 85.2 0 0 0 409 410.3l1.2-1.2a85.2 85.2 0 0 0-43.6-143.7Z");
  var MAX_TILES = 6000;

  // GSAP 3 ease formulas ("in" versions are 1 - out(1 - p) where GSAP defines them that way)
  function bounceOut(p) {
    var n = 7.5625, c = 2.75, n1 = 1 / c;
    return p < n1 ? n * p * p : p < 2 * n1 ? n * Math.pow(p - 1.5 / c, 2) + 0.75 : p < 2.5 * n1 ? n * Math.pow(p - 2.25 / c, 2) + 0.9375 : n * Math.pow(p - 2.625 / c, 2) + 0.984375;
  }
  var EP = 2 * Math.PI / 0.3, ES = 0.3 / (2 * Math.PI) * Math.asin(1); // elastic: amplitude 1, period 0.3
  function elasticOut(p) { return p === 1 ? 1 : Math.pow(2, -10 * p) * Math.sin((p - ES) * EP) + 1; }
  function backOut(p) { var s = 1.70158; return p ? ((--p) * p * ((s + 1) * p + s) + 1) : 0; }
  var EASES = {
    "power1.in": function (p) { return p * p; },
    "power2.in": function (p) { return p * p * p; },
    "power3.in": function (p) { return p * p * p * p; },
    "power4.in": function (p) { return p * p * p * p * p; },
    "back.in": function (p) { return 1 - backOut(1 - p); },
    "circ.in": function (p) { return -(Math.sqrt(1 - p * p) - 1); },
    "expo.in": function (p) { return p ? Math.pow(2, 10 * (p - 1)) : 0; },
    "bounce.in": function (p) { return 1 - bounceOut(1 - p); },
    "elastic.in": function (p) { return 1 - elasticOut(1 - p); }
  };
  var PALETTES = {
    enrichr: { primary: "#fbf8f1", secondary: "#e6ff55", background: "#0c1e18", stroke: "#085e3d" },
    noir: { primary: "#fbf8f1", secondary: "#d9d3c4", background: "#050d0a", stroke: "#0c1e18" },
    forest: { primary: "#1a7a4c", secondary: "#e6ff55", background: "#050d0a", stroke: "#003d28" },
    ember: { primary: "#e54c1e", secondary: "#fbf8f1", background: "#0c1e18", stroke: "#050d0a" },
    cream: { primary: "#0c1e18", secondary: "#4b1a9a", background: "#fbf8f1", stroke: "#e4ddcc" }
  };
  var COLOR_KEYS = ["primary", "secondary", "background", "stroke"];
  var DEF = { ease: "power2.in", sequence: "diagonal", degrees: 360, duration: 1.2, stagger: 0.03, rest: 0.5, tile: 72, petal: 1, angle: 0,
    mode: "solid", glow: 0, hover: 1, palette: "enrichr", primary: "#fbf8f1", secondary: "#e6ff55", background: "#0c1e18", stroke: "#085e3d" };

  var opts = {}, col = {}, custom = {}, mounted = false;
  var raf = 0, visible = true, io = null, ro = null, destroyed = false, last = 0;
  var W = 0, H = 0, dpr = 1, S = 72, tiles = [], tl = null, spins = [];
  var mouse = { x: -1000, y: -1000, active: false };

  function num(v, d) { v = Number(v); return isFinite(v) ? v : d; }
  function rgb(h) { var m = /^#?([0-9a-f]{6})$/i.exec(h || ""); var v = parseInt(m ? m[1] : "000000", 16); return [v >> 16, (v >> 8) & 255, v & 255]; }
  function mixColor(a, b, f) {
    f = Math.max(0, Math.min(1, f));
    return "rgb(" + Math.round(a[0] + f * (b[0] - a[0])) + ", " + Math.round(a[1] + f * (b[1] - a[1])) + ", " + Math.round(a[2] + f * (b[2] - a[2])) + ")";
  }

  // grid: square, as wide as the box diagonal plus four spare tiles, centred, so a rotated grid still covers every corner
  function build() {
    S = opts.tile;
    var diag = Math.hypot(W, H), n = Math.ceil(diag / S) + 4;
    while (n * n > MAX_TILES) { S *= 1.05; n = Math.ceil(diag / S) + 4; }
    var rows = n, cols = n, startX = W / 2 - cols * S / 2, startY = H / 2 - rows * S / 2, maxDiag = Math.hypot(cols, rows), seq = opts.sequence;
    tiles = [];
    for (var r = 0; r < rows; r++) {
      for (var c = 0; c < cols; c++) {
        var f = 0;
        if (seq === "radial") f = Math.hypot(r - rows / 2, c - cols / 2) / (maxDiag / 2);
        else if (seq === "row") f = r / rows;
        else if (seq === "col") f = c / cols;
        else if (seq === "random") f = Math.random();
        else f = (c + r) / (cols + rows);
        tiles.push({ r: r, c: c, cx: startX + c * S + S / 2, cy: startY + r * S + S / 2, rotation: 0, factor: f, delay: 0, from: null, to: 0 });
      }
    }
    trigger();
  }

  // a fresh timeline: every star tweens from where it is now to (its angle now + degrees) after its own delay
  function trigger() {
    spins = [];
    var total = 0, stag = opts.stagger * tiles.length;
    for (var i = 0; i < tiles.length; i++) {
      var t = tiles[i];
      t.delay = t.factor * stag; t.from = null; t.to = t.rotation + opts.degrees;
      if (t.delay + opts.duration > total) total = t.delay + opts.duration;
    }
    tl = { time: 0, local: -1e-4, iter: 0, total: total, cycle: Math.max(0.001, total + opts.rest), dur: Math.max(0.001, opts.duration), ease: EASES[opts.ease] || EASES["power2.in"] };
  }
  // the playhead moved from a to b: render every tween it touched (started, running, or just finished)
  function renderRange(a, b) {
    if (b <= a) return;
    for (var i = 0; i < tiles.length; i++) {
      var t = tiles[i], s = t.delay;
      if (b < s || a >= s + tl.dur) continue;
      if (t.from === null) t.from = t.rotation;
      var p = (b - s) / tl.dur; p = p > 1 ? 1 : p;
      t.rotation = t.from + (t.to - t.from) * tl.ease(p);
    }
  }
  function step(dt) {
    tl.time += dt;
    var iter = Math.floor(tl.time / tl.cycle), local = Math.min(tl.time - iter * tl.cycle, tl.total);
    if (iter !== tl.iter) {
      renderRange(tl.local, tl.total); // finish the pass
      for (var i = 0; i < tiles.length; i++) if (tiles[i].from !== null) tiles[i].rotation = tiles[i].from; // repeat: back to the start
      tl.iter = iter; tl.local = -1e-4;
    }
    renderRange(tl.local, local);
    tl.local = local;
    // clicked stars: separate tweens that run on top of the timeline, like gsap.to() calls
    for (var j = spins.length - 1, k; j >= 0; j--) {
      k = spins[j]; k.t += dt;
      if (k.from === null) k.from = k.tile.rotation;
      var q = Math.min(1, k.t / k.dur);
      k.tile.rotation = k.from + (k.to - k.from) * k.ease(q);
      if (q >= 1) spins.splice(j, 1);
    }
  }

  function motif(cx, cy, size, dist, rot, fill, stroke, width) {
    ctx.save();
    ctx.translate(cx, cy);
    ctx.rotate(rot * Math.PI / 180);
    var s = size / 480 * dist;
    ctx.scale(s, s);
    ctx.translate(-240, -240);
    if (fill) { ctx.fillStyle = fill; ctx.fill(MOTIF); }
    if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = width / s; ctx.stroke(MOTIF); }
    ctx.restore();
  }

  function draw() {
    var cx = W / 2, cy = H / 2, ga = opts.angle * Math.PI / 180, ca = Math.cos(ga), sa = Math.sin(ga);
    var c1 = rgb(col.primary), c2 = rgb(col.secondary), live = !reduce && opts.hover && mouse.active, mode = opts.mode;
    var strokeOn = col.stroke.toLowerCase() !== col.background.toLowerCase() ? col.stroke : null;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.shadowBlur = 0;
    ctx.fillStyle = col.background;
    ctx.fillRect(0, 0, W, H);
    ctx.save();
    ctx.translate(cx, cy); ctx.rotate(ga); ctx.translate(-cx, -cy);
    for (var i = 0; i < tiles.length; i++) {
      var t = tiles[i], hb = 0;
      if (live) {
        var dx = t.cx - cx, dy = t.cy - cy;
        var d = Math.hypot(cx + dx * ca - dy * sa - mouse.x, cy + dx * sa + dy * ca - mouse.y);
        if (d < 180) hb = Math.pow(1 - d / 180, 2);
      }
      var size = S * (1 + hb * 0.25), dist = opts.petal * (1 + hb * 0.3);
      var color = mixColor(c1, c2, Math.abs((t.rotation % 360) / 360 * 0.8 + hb * 0.4));
      if (opts.glow > 0 || hb > 0) { ctx.shadowColor = color; ctx.shadowBlur = (opts.glow + hb * 20) * dpr; }
      else ctx.shadowBlur = 0;
      if (mode === "wireframe") motif(t.cx, t.cy, size, dist, t.rotation, null, color, 2);
      else if (mode === "glow") {
        motif(t.cx, t.cy, size, dist, t.rotation, null, color, 3);
        motif(t.cx, t.cy, size * 0.5, dist, -t.rotation, col.secondary, null, 0);
      } else if (mode === "checker") motif(t.cx, t.cy, size, dist, t.rotation, (t.c + t.r) % 2 === 0 ? color : col.secondary, null, 0);
      else motif(t.cx, t.cy, size, dist, t.rotation, color, strokeOn, 1);
    }
    ctx.restore();
    ctx.shadowBlur = 0;
  }

  // reduced motion: one still frame 40% of the way through the first wave, so the spinning band still shows
  function still() {
    if (!tl) return;
    var at = tl.total * 0.4;
    for (var i = 0; i < tiles.length; i++) {
      var t = tiles[i], p = (at - t.delay) / tl.dur;
      p = p <= 0 ? 0 : p >= 1 ? 1 : p;
      t.rotation = p > 0 ? t.to * tl.ease(p) : 0;
    }
    draw();
  }

  function loop(now) {
    raf = requestAnimationFrame(loop);
    var dt = (now - last) / 1000 || 0; last = now;
    if (dt > 0.5) dt = 0.033; // like GSAP's lag smoothing: a long stall resumes instead of jumping
    if (!visible || document.hidden || !tl) return;
    step(dt);
    draw();
  }

  function size() {
    var w = wrap.clientWidth, h = wrap.clientHeight, nd = Math.min(window.devicePixelRatio || 1, 2);
    if (w === W && h === H && nd === dpr && tiles.length) return;
    var rebuild = w !== W || h !== H || !tiles.length;
    W = Math.max(1, w); H = Math.max(1, h); dpr = nd;
    canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr);
    if (rebuild) build();
    if (reduce) still(); else draw();
  }

  function point(e) { var r = wrap.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }
  function onMove(e) { var p = point(e); mouse.x = p.x; mouse.y = p.y; mouse.active = true; }
  function onLeave() { mouse.active = false; }
  function onClick(e) {
    if (reduce) return;
    var p = point(e), cx = W / 2, cy = H / 2, ga = opts.angle * Math.PI / 180, ca = Math.cos(ga), sa = Math.sin(ga);
    for (var i = 0; i < tiles.length; i++) {
      var t = tiles[i], dx = t.cx - cx, dy = t.cy - cy;
      if (Math.hypot(cx + dx * ca - dy * sa - p.x, cy + dx * sa + dy * ca - p.y) < opts.tile * 1.5) {
        spins.push({ tile: t, from: null, to: t.rotation + opts.degrees, t: 0, dur: Math.max(0.001, opts.duration), ease: EASES[opts.ease] || EASES["power2.in"] });
      }
    }
  }

  function update(partial) {
    partial = partial || {};
    var prev = opts;
    opts = Object.assign({}, DEF, opts, partial);
    opts.degrees = num(opts.degrees, 360); opts.duration = Math.max(0.05, num(opts.duration, 1.2));
    opts.stagger = Math.max(0, num(opts.stagger, 0.03)); opts.rest = Math.max(0, num(opts.rest, 0.5));
    opts.tile = Math.max(12, num(opts.tile, 72)); opts.petal = num(opts.petal, 1); opts.angle = num(opts.angle, 0);
    opts.glow = Math.max(0, num(opts.glow, 0)); opts.hover = String(opts.hover) === "1" || opts.hover === true;
    // colours: the palette sets all four; a colour you pick yourself wins for that role
    if (!mounted) { COLOR_KEYS.forEach(function (k) { custom[k] = partial[k] != null && String(partial[k]).toLowerCase() !== DEF[k]; }); }
    else {
      if ("palette" in partial) custom = {};
      COLOR_KEYS.forEach(function (k) { if (k in partial) custom[k] = true; });
    }
    var pal = PALETTES[opts.palette] || PALETTES.enrichr;
    COLOR_KEYS.forEach(function (k) { col[k] = custom[k] ? String(opts[k]) : pal[k]; });
    wrap.style.background = col.background;
    if (destroyed || !mounted) return;
    if (prev.tile !== opts.tile || prev.sequence !== opts.sequence) build();
    else if (prev.ease !== opts.ease || prev.degrees !== opts.degrees || prev.duration !== opts.duration || prev.stagger !== opts.stagger || prev.rest !== opts.rest) trigger();
    if (reduce) still(); else draw();
  }

  update(o);
  mounted = true;
  size();
  wrap.addEventListener("pointermove", onMove);
  wrap.addEventListener("pointerleave", onLeave);
  wrap.addEventListener("pointercancel", onLeave);
  wrap.addEventListener("click", onClick);
  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) size(); }); ro.observe(wrap); }
  return {
    update: update,
    destroy: function () {
      destroyed = true; cancelAnimationFrame(raf);
      wrap.removeEventListener("pointermove", onMove);
      wrap.removeEventListener("pointerleave", onLeave);
      wrap.removeEventListener("pointercancel", onLeave);
      wrap.removeEventListener("click", onClick);
      if (io) io.disconnect();
      if (ro) ro.disconnect();
      tiles = []; spins = [];
    }
  };
}

// Usage
var fx = mount(document.querySelector("#my-root"), {"ease":"power2.in","sequence":"diagonal","degrees":360,"duration":1.2,"stagger":0.03,"rest":0.5,"tile":72,"petal":1,"angle":0,"mode":"solid","glow":0,"hover":1,"palette":"enrichr","primary":"#fbf8f1","secondary":"#e6ff55","background":"#0c1e18","stroke":"#085e3d"});

How it works

The stars sit on a square grid as wide as the box's diagonal plus four spare tiles, centred, so the grid can be turned to any angle and still cover the corners. Each star is the same four-petal vector path. A hand-built timeline replaces the original's GSAP timeline: every star gets a delay of its position factor times the stagger times the number of stars. The diagonal factor is (column plus row) divided by (columns plus rows); centre outward uses the distance from the middle, rows and columns use their index, random uses a random number. Each star then turns by the spin angle over the spin duration, through the same ease formulas GSAP uses (power2.in is progress cubed, back.in pulls back first, bounce.in and elastic.in use GSAP's constants). After the last star finishes and the rest has passed, the timeline repeats and every star snaps back to its start angle, which looks the same because the star has four-fold symmetry. A star's colour moves from the petal colour toward the accent as it turns. Near the pointer, stars grow by up to 25%, fatten their petals by up to 30%, lean toward the accent and glow; a click spins the stars around that point at once, on top of the wave.

Accessibility and performance

The canvas is decorative and has a text label. With prefers-reduced-motion it draws one still frame part way through a wave, so the band of turned stars still shows, and it ignores the pointer. Spins are smooth turns, not flashes. The click spin is a pointer extra; nothing needs the keyboard and the canvas adds no tab stop.

Each star is one Path2D fill and outline; a 1280 by 720 box holds about 600 stars at the default tile size, and the grid is capped near 6,000. Canvas glow is the costly part, so Glow defaults to 0 and only the stars under the pointer glow. In a test at 1280 by 720 with a GPU it held 60 frames a second. The pixel ratio is capped at 2 and the loop pauses off screen or in a hidden tab.

SectionBackground
TechniquesStagger, Pointer interaction
StackVanilla JS, Canvas
Works withReact, Next.js, Vanilla
DependenciesNone
Statustested
On CodePenOpen the live pen on our CodePen account; fork it to experiment.
Inspired byQuad-Petal Star Matrix 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.

Distributed letters hoverA big headline whose letters scatter evenly across the stage on hover or button press, then spring back into the word. Blur title sliderA big title and a column of tilted, blurred photos that slide one place along a curve on each change, while the title letters roll over and the background colour blends. Flight path heroA photo hero with a gradient headline that drops in letter by letter, a plane flying a soft contrail, and a menu button that wipes a full-screen menu down while the page swings away.

FAQ

Does it need GSAP?

No. Each star's progress is (time minus its delay) divided by the duration, clamped between 0 and 1 and run through the same ease formula GSAP uses. The timeline repeats after the rest and resets each star to its start angle, as a GSAP timeline with repeat -1 and repeatDelay does.

Why does one wave take so long on a big screen?

As in the original, the total stagger is the stagger setting times the number of stars, so a bigger box has a longer wave, about 19 seconds at 1280 by 720. Lower Stagger per star or raise Tile size for faster waves.

What does the palette do?

A palette sets the four colours at once. A colour you pick yourself after that overrides the palette for that one role.

Can I click it?

Yes. A click spins the stars within one and a half tiles of that point straight away, with the same angle, duration and ease, on top of the running wave.