Reveal Ripple Tiles

Media

Media reveal driven by a height-field water sim: a ripple front sweeps from the trigger point, popping grid tiles open as the wave crosses them and refracting revealed tiles like wet glass; the cursor keeps stirring the water after full reveal.

Install
npx shadcn add https://design.helpmarq.com/r/reveal-ripple-tiles.json
Source
registry/core/reveal-ripple-tiles/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// RippleUnfold — media behind a coarse tile grid. A height-field wave sim
// sweeps a ripple front out from the trigger point (scroll-into-view or first
// pointer entry): tiles pop open when local amplitude crosses a threshold,
// and revealed tiles refract their source rect along the wave gradient like
// wet glass. After full reveal the cursor keeps perturbing the water.
// Single DPR-aware Canvas 2D, direct-DOM rAF loop that sleeps when the field
// and every tile spring settle. All drawn ink derives from CSS tokens at
// mount and re-derives live on documentElement class changes.
// ---------------------------------------------------------------------------

type RGB = [number, number, number];

interface Palette {
  background: RGB;
  foreground: RGB;
  surface: RGB;
  border: RGB;
  muted: RGB;
  accent: RGB;
}

const FALLBACK_DARK: Palette = {
  background: [10, 10, 10],
  foreground: [237, 237, 237],
  surface: [23, 23, 23],
  border: [46, 46, 46],
  muted: [143, 143, 143],
  accent: [0, 107, 255],
};

const FALLBACK_LIGHT: Palette = {
  background: [255, 255, 255],
  foreground: [23, 23, 23],
  surface: [250, 250, 250],
  border: [235, 235, 235],
  muted: [143, 143, 143],
  accent: [0, 107, 255],
};

const EPS = 0.004; // dual sleep epsilon for max|h| and max|v|
const SPRING_K = 90; // s^-2
const SPRING_C = 2 * 0.7 * Math.sqrt(SPRING_K); // zeta = 0.7
const SQUASH_MS = 320;
const DT_MAX = 0.033;

function css(c: RGB, a: number) {
  return `rgba(${c[0]},${c[1]},${c[2]},${a})`;
}

// resolve a CSS custom property to rgb via an in-document probe element
function readToken(probe: HTMLElement, token: string, fallback: RGB): RGB {
  probe.style.color = "";
  probe.style.color = `var(${token})`;
  const raw = getComputedStyle(probe).color;
  let m = raw.match(/rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/);
  if (m) return [+m[1]!, +m[2]!, +m[3]!];
  m = raw.match(/color\(srgb\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
  if (m) {
    return [
      Math.round(+m[1]! * 255),
      Math.round(+m[2]! * 255),
      Math.round(+m[3]! * 255),
    ];
  }
  return fallback;
}

// cubic-bezier(0.22, 1, 0.36, 1) solved via Newton–Raphson
function makeBezier(p1x: number, p1y: number, p2x: number, p2y: number) {
  const cx = 3 * p1x;
  const bx = 3 * (p2x - p1x) - cx;
  const ax = 1 - cx - bx;
  const cy = 3 * p1y;
  const by = 3 * (p2y - p1y) - cy;
  const ay = 1 - cy - by;
  const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t;
  const sampleY = (t: number) => ((ay * t + by) * t + cy) * t;
  const slopeX = (t: number) => (3 * ax * t + 2 * bx) * t + cx;
  return (x: number) => {
    if (x <= 0) return 0;
    if (x >= 1) return 1;
    let t = x;
    for (let i = 0; i < 6; i++) {
      const s = slopeX(t);
      if (Math.abs(s) < 1e-6) break;
      t -= (sampleX(t) - x) / s;
    }
    return sampleY(Math.min(1, Math.max(0, t)));
  };
}
const glideEase = makeBezier(0.22, 1, 0.36, 1);

export function RippleUnfold({
  src,
  tileSize = 32,
  threshold = 0.12,
  refraction = 6,
  impulse = 0.35,
  className = "aspect-[16/10]",
  "aria-label": ariaLabel = "Media revealed by a ripple wave",
}: {
  /** image URL; omitted → a token-derived generative artwork is drawn */
  src?: string;
  /** target tile edge in px; cols/rows derive from container size */
  tileSize?: number;
  /** |h| a tile must see before it pops open */
  threshold?: number;
  /** max source-rect refraction offset in px per unit wave gradient */
  refraction?: number;
  /** wave height injected per cursor move after full reveal */
  impulse?: number;
  className?: string;
  "aria-label"?: string;
}) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    const off = document.createElement("canvas");
    const octx = off.getContext("2d");
    if (!octx) return;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;

    // hot-path state — locals only, the rAF loop is the sole writer
    let w = 0;
    let h = 0;
    let dpr = 1;
    let cols = 0;
    let rows = 0;
    let tw = 0;
    let th = 0;
    let total = 0;
    let hf = new Float32Array(0); // wave height
    let vf = new Float32Array(0); // wave velocity
    let stateArr = new Uint8Array(0); // 0 closed, 1 open/scheduled
    let scaleArr = new Float32Array(0); // tile spring scale
    let svelArr = new Float32Array(0); // tile spring velocity
    let openAtArr = new Float64Array(0); // timestamp the tile starts opening
    let openedCount = 0;
    let triggered = false;
    let trigCol = 0;
    let trigRow = 0;
    let maxH = 0;
    let maxV = 0;
    let pendingPointer = -1; // cell index — throttles impulses to one/frame
    let raf = 0;
    let last = 0;
    let visible = true;
    let disposed = false;
    let img: HTMLImageElement | null = null;
    let imgReady = false;

    // theme ink — derived from CSS tokens, re-derived on theme class change
    let pal: Palette = FALLBACK_DARK;
    let borderStroke = "";
    let fgFaint = "";
    let accentPrefix = "";

    const deriveColors = () => {
      const dark = document.documentElement.classList.contains("dark");
      const fb = dark ? FALLBACK_DARK : FALLBACK_LIGHT;
      const probe = document.createElement("span");
      probe.style.display = "none";
      root.appendChild(probe);
      pal = {
        background: readToken(probe, "--background", fb.background),
        foreground: readToken(probe, "--foreground", fb.foreground),
        surface: readToken(probe, "--surface", fb.surface),
        border: readToken(probe, "--border", fb.border),
        muted: readToken(probe, "--muted", fb.muted),
        accent: readToken(probe, "--accent", fb.accent),
      };
      probe.remove();
      borderStroke = css(pal.border, 1);
      fgFaint = css(pal.foreground, 0.05);
      accentPrefix = `rgba(${pal.accent[0]},${pal.accent[1]},${pal.accent[2]},`;
    };

    // token-derived generative artwork used when no src is provided —
    // monochrome orbital study so the demo needs no network fetch
    const paintArtwork = () => {
      const W = off.width;
      const H = off.height;
      if (W < 2 || H < 2) return;
      const o = octx;
      const m = Math.min(W, H);
      o.setTransform(1, 0, 0, 1, 0, 0);
      o.fillStyle = css(pal.surface, 1);
      o.fillRect(0, 0, W, H);

      // diagonal hatch field, lower-left
      o.strokeStyle = css(pal.foreground, 0.05);
      o.lineWidth = Math.max(1, m / 640);
      o.beginPath();
      const hatch = m * 0.022;
      for (let x = -H; x < W * 0.6; x += hatch) {
        o.moveTo(x, H);
        o.lineTo(x + H * 0.9, H * 0.1);
      }
      o.stroke();

      // concentric orbit rings
      const ocx = W * 0.63;
      const ocy = H * 0.4;
      for (let i = 0; i < 7; i++) {
        o.strokeStyle = css(pal.muted, 0.55 - i * 0.065);
        o.lineWidth = Math.max(1, m / 520);
        o.beginPath();
        o.arc(ocx, ocy, m * (0.07 + 0.075 * i), 0, Math.PI * 2);
        o.stroke();
      }
      // core disc + satellite on the third ring
      o.fillStyle = css(pal.foreground, 0.92);
      o.beginPath();
      o.arc(ocx, ocy, m * 0.048, 0, Math.PI * 2);
      o.fill();
      const orbitR = m * (0.07 + 0.075 * 3);
      o.beginPath();
      o.arc(
        ocx + Math.cos(-0.6) * orbitR,
        ocy + Math.sin(-0.6) * orbitR,
        m * 0.016,
        0,
        Math.PI * 2
      );
      o.fill();

      // instrumentation baseline + ticks
      const by = H * 0.82;
      o.strokeStyle = css(pal.muted, 0.7);
      o.lineWidth = Math.max(1, m / 640);
      o.beginPath();
      o.moveTo(W * 0.08, by);
      o.lineTo(W * 0.92, by);
      o.stroke();
      o.strokeStyle = css(pal.foreground, 0.6);
      o.beginPath();
      for (let i = 0; i <= 12; i++) {
        const tx = W * 0.08 + (W * 0.84 * i) / 12;
        o.moveTo(tx, by - m * 0.012);
        o.lineTo(tx, by + m * 0.012);
      }
      o.stroke();

      // inset frame
      o.strokeStyle = css(pal.muted, 0.3);
      o.lineWidth = Math.max(1, m / 640);
      o.strokeRect(m * 0.035, m * 0.035, W - m * 0.07, H - m * 0.07);
    };

    const paintOffscreen = () => {
      if (off.width < 2 || off.height < 2) return;
      if (imgReady && img && img.naturalWidth > 0 && img.naturalHeight > 0) {
        const sc = Math.max(
          off.width / img.naturalWidth,
          off.height / img.naturalHeight
        );
        const dw = img.naturalWidth * sc;
        const dh = img.naturalHeight * sc;
        octx.setTransform(1, 0, 0, 1, 0, 0);
        octx.fillStyle = css(pal.surface, 1);
        octx.fillRect(0, 0, off.width, off.height);
        octx.drawImage(img, (off.width - dw) / 2, (off.height - dh) / 2, dw, dh);
        return;
      }
      paintArtwork();
    };

    const revealAll = () => {
      for (let idx = 0; idx < total; idx++) {
        stateArr[idx] = 1;
        scaleArr[idx] = 1;
        svelArr[idx] = 0;
        openAtArr[idx] = -1e6; // squash long finished
      }
      openedCount = total;
    };

    const rebuildGrid = () => {
      cols = Math.max(2, Math.min(96, Math.round(w / Math.max(8, tileSize))));
      rows = Math.max(2, Math.min(96, Math.round(h / Math.max(8, tileSize))));
      tw = w / cols;
      th = h / rows;
      total = cols * rows;
      hf = new Float32Array(total);
      vf = new Float32Array(total);
      stateArr = new Uint8Array(total);
      scaleArr = new Float32Array(total);
      svelArr = new Float32Array(total);
      openAtArr = new Float64Array(total);
      openedCount = 0;
      maxH = 0;
      maxV = 0;
      pendingPointer = -1;
      // resizing mid-reveal restarts nothing: an already-triggered surface
      // settles straight to fully revealed
      if (triggered) revealAll();
    };

    const resize = () => {
      const rect = root.getBoundingClientRect();
      if (rect.width < 4 || rect.height < 4) {
        // zero-size guard — no canvas work until we have real bounds
        w = 0;
        h = 0;
        total = 0;
        return;
      }
      w = rect.width;
      h = rect.height;
      dpr = Math.min(2, window.devicePixelRatio || 1);
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      off.width = canvas.width;
      off.height = canvas.height;
      paintOffscreen();
      rebuildGrid();
    };

    // classic discrete wave equation, Neumann edges, damping 0.985/step
    // (0.95/step once every tile has opened — nothing is visibly changing
    // by then, so the idle tail decays below EPS in under a second instead
    // of several)
    const step = () => {
      const c = cols;
      const damping = openedCount >= total ? 0.95 : 0.985;
      for (let j = 0; j < rows; j++) {
        const row = j * c;
        for (let i = 0; i < c; i++) {
          const idx = row + i;
          const hc = hf[idx]!;
          const l = i > 0 ? hf[idx - 1]! : hc;
          const r = i < c - 1 ? hf[idx + 1]! : hc;
          const u = j > 0 ? hf[idx - c]! : hc;
          const d = j < rows - 1 ? hf[idx + c]! : hc;
          vf[idx] = (vf[idx]! + ((l + r + u + d) * 0.25 - hc) * 0.5) * damping;
        }
      }
      maxH = 0;
      maxV = 0;
      for (let idx = 0; idx < total; idx++) {
        const nh = hf[idx]! + vf[idx]!;
        hf[idx] = nh;
        const ah = nh < 0 ? -nh : nh;
        const av = vf[idx]! < 0 ? -vf[idx]! : vf[idx]!;
        if (ah > maxH) maxH = ah;
        if (av > maxV) maxV = av;
      }
    };

    const openTile = (idx: number, at: number) => {
      stateArr[idx] = 1;
      scaleArr[idx] = 0.6;
      svelArr[idx] = 0;
      openAtArr[idx] = at;
      openedCount++;
    };

    // safeguard: a coarse wave can die below threshold before reaching the
    // far corners — schedule the stragglers outward from the trigger cell
    const flush = (now: number) => {
      for (let j = 0; j < rows; j++) {
        for (let i = 0; i < cols; i++) {
          const idx = j * cols + i;
          if (stateArr[idx] === 0) {
            openTile(idx, now + Math.hypot(i - trigCol, j - trigRow) * 16);
          }
        }
      }
    };

    // draw + tile springs; returns whether anything still animates
    const render = (now: number, dt: number): boolean => {
      let animating = false;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);
      ctx.lineWidth = 1;
      const offW = off.width;
      const offH = off.height;
      const swD = tw * dpr;
      const shD = th * dpr;
      const maxSx = Math.max(0, offW - swD);
      const maxSy = Math.max(0, offH - shD);

      for (let j = 0; j < rows; j++) {
        for (let i = 0; i < cols; i++) {
          const idx = j * cols + i;
          const x = i * tw;
          const y = j * th;
          const opened = stateArr[idx] === 1 && now >= openAtArr[idx]!;

          if (!opened) {
            if (stateArr[idx] === 1) animating = true; // scheduled, waiting
            // closed tile: faint fg lift + border hairline + accent shimmer
            ctx.fillStyle = fgFaint;
            ctx.fillRect(x + 0.5, y + 0.5, tw - 1, th - 1);
            const amp = Math.abs(hf[idx] ?? 0);
            if (amp > 0.012) {
              ctx.fillStyle =
                accentPrefix + Math.min(0.4, amp * 0.85).toFixed(3) + ")";
              ctx.fillRect(x + 0.5, y + 0.5, tw - 1, th - 1);
            }
            ctx.strokeStyle = borderStroke;
            ctx.strokeRect(x + 0.5, y + 0.5, tw - 1, th - 1);
            continue;
          }

          // open spring: k = 90 s^-2, zeta = 0.7, scale 0.6 → 1
          let s = scaleArr[idx]!;
          let sv = svelArr[idx]!;
          if (s !== 1 || sv !== 0) {
            sv += (SPRING_K * (1 - s) - SPRING_C * sv) * dt;
            s += sv * dt;
            if (Math.abs(1 - s) < 0.002 && Math.abs(sv) < 0.02) {
              s = 1;
              sv = 0;
            } else {
              animating = true;
            }
            scaleArr[idx] = s;
            svelArr[idx] = sv;
          }
          // vertical squash 35% → 0 over 320ms, glide ease
          const tq = (now - openAtArr[idx]!) / SQUASH_MS;
          let squash = 1;
          if (tq < 1) {
            squash = 0.65 + 0.35 * glideEase(Math.max(0, tq));
            animating = true;
          }

          // wet-glass refraction: source rect offset by the wave gradient
          const hc = hf[idx]!;
          const l = i > 0 ? hf[idx - 1]! : hc;
          const r = i < cols - 1 ? hf[idx + 1]! : hc;
          const u = j > 0 ? hf[idx - cols]! : hc;
          const d = j < rows - 1 ? hf[idx + cols]! : hc;
          const sx = Math.min(
            Math.max(x * dpr + (r - l) * 0.5 * refraction * dpr, 0),
            maxSx
          );
          const sy = Math.min(
            Math.max(y * dpr + (d - u) * 0.5 * refraction * dpr, 0),
            maxSy
          );

          if (s === 1 && squash === 1) {
            ctx.drawImage(off, sx, sy, swD, shD, x, y, tw, th);
          } else {
            ctx.save();
            ctx.translate(x + tw / 2, y + th / 2);
            ctx.scale(s, s * squash);
            ctx.drawImage(off, sx, sy, swD, shD, -tw / 2, -th / 2, tw, th);
            ctx.restore();
          }
        }
      }
      return animating;
    };

    const loop = (now: number) => {
      raf = 0;
      if (disposed || w === 0 || total === 0) return;
      const dt = Math.min(Math.max((now - last) / 1000, 0), DT_MAX);
      last = now;
      let animating = false;

      if (triggered) {
        if (pendingPointer >= 0 && pendingPointer < total) {
          hf[pendingPointer] = Math.min(1.5, hf[pendingPointer]! + impulse);
          pendingPointer = -1;
        }
        step();
        step();
        if (maxH > EPS || maxV > EPS) {
          animating = true;
        } else if (maxH > 0 || maxV > 0) {
          // settle hard so refraction offsets are exactly zero at rest
          hf.fill(0);
          vf.fill(0);
          maxH = 0;
          maxV = 0;
        }
        if (openedCount < total) {
          for (let idx = 0; idx < total; idx++) {
            if (stateArr[idx] === 0 && Math.abs(hf[idx]!) > threshold) {
              openTile(idx, now);
            }
          }
          if (openedCount < total && maxH < threshold * 0.5) flush(now);
          animating = true;
        }
      }

      if (render(now, dt)) animating = true;
      if (animating && visible) raf = requestAnimationFrame(loop);
    };

    const wake = () => {
      if (raf === 0 && visible && !disposed && w > 0) {
        last = performance.now();
        raf = requestAnimationFrame(loop);
      }
    };

    const drawOnce = () => {
      if (w > 0 && total > 0) render(performance.now(), 0);
    };

    const repaint = () => {
      if (reduced) drawOnce();
      else wake();
    };

    const trigger = (ci: number, ri: number) => {
      if (triggered || disposed) return;
      triggered = true;
      if (total === 0) return; // grid rebuild reveals on next real size
      if (reduced) {
        // reduced motion: single instant paint, zero wave distortion
        revealAll();
        drawOnce();
        return;
      }
      trigCol = Math.min(Math.max(ci, 0), cols - 1);
      trigRow = Math.min(Math.max(ri, 0), rows - 1);
      hf[trigRow * cols + trigCol] = 1;
      maxH = 1;
      wake();
    };

    const cellAt = (e: PointerEvent): [number, number] | null => {
      if (total === 0) return null;
      const rect = root.getBoundingClientRect();
      if (rect.width < 1 || rect.height < 1) return null;
      const ci = Math.min(
        Math.max(Math.floor(((e.clientX - rect.left) / rect.width) * cols), 0),
        cols - 1
      );
      const ri = Math.min(
        Math.max(Math.floor(((e.clientY - rect.top) / rect.height) * rows), 0),
        rows - 1
      );
      return [ci, ri];
    };

    const onPointer = (e: PointerEvent) => {
      if (!triggered) {
        const c = cellAt(e);
        trigger(c ? c[0] : cols >> 1, c ? c[1] : rows >> 1);
        return;
      }
      // cursor keeps perturbing the water only after full reveal;
      // pendingPointer throttles injection to one impulse per frame
      if (reduced || openedCount < total || total === 0) return;
      const c = cellAt(e);
      if (!c) return;
      pendingPointer = c[1] * cols + c[0];
      wake();
    };

    deriveColors();
    resize();
    repaint();

    // scroll trigger at 0.35 visibility + offscreen pause
    const io = new IntersectionObserver(
      (entries) => {
        for (const e of entries) {
          visible = e.isIntersecting;
          if (!triggered && e.intersectionRatio >= 0.35) {
            trigger(cols >> 1, rows >> 1);
          }
        }
        if (visible) {
          if (!reduced) wake();
        } else if (raf) {
          cancelAnimationFrame(raf);
          raf = 0;
        }
      },
      { threshold: [0, 0.35] }
    );
    io.observe(root);

    const ro = new ResizeObserver((entries) => {
      const e = entries[0];
      if (!e) return;
      if (
        Math.abs(e.contentRect.width - w) < 1 &&
        Math.abs(e.contentRect.height - h) < 1
      ) {
        return;
      }
      resize();
      repaint();
    });
    ro.observe(root);

    // live theme re-derive: watch documentElement class changes
    const mo = new MutationObserver(() => {
      deriveColors();
      if (!imgReady) paintOffscreen(); // artwork ink is token-derived
      repaint();
    });
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    root.addEventListener("pointerenter", onPointer);
    root.addEventListener("pointermove", onPointer);
    root.addEventListener("pointerdown", onPointer);

    if (src) {
      img = new Image();
      img.decoding = "async";
      img.onload = () => {
        if (disposed) return;
        imgReady = true;
        paintOffscreen();
        repaint();
      };
      img.onerror = () => {
        // keep the generative artwork — never a blank surface
        imgReady = false;
      };
      img.src = src;
    }

    return () => {
      disposed = true;
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      io.disconnect();
      ro.disconnect();
      mo.disconnect();
      root.removeEventListener("pointerenter", onPointer);
      root.removeEventListener("pointermove", onPointer);
      root.removeEventListener("pointerdown", onPointer);
      if (img) {
        img.onload = null;
        img.onerror = null;
      }
    };
  }, [src, tileSize, threshold, refraction, impulse]);

  return (
    <div
      ref={rootRef}
      role="img"
      aria-label={ariaLabel}
      className={`relative w-full select-none overflow-hidden ${className}`}
    >
      <canvas
        ref={canvasRef}
        aria-hidden
        className="absolute inset-0 h-full w-full"
      />
    </div>
  );
}
Build spec

A media surface behind a coarse tile grid on a single DPR-aware Canvas 2D (DPR clamp 2), image pre-drawn to an offscreen canvas (or a token-derived generative artwork when no src is given). A height-field wave sim runs on a grid matched to tile resolution (~28x18 cells, tile ≈ 32px at 900px width; cols derived from width/32, zero-size guarded): classic discrete wave equation — v[c] += (4-neighbor avg − h[c]) * 0.5 per step, h += v, damping ×0.985/step (×0.95/step once every tile has opened, to shorten the inaudible idle tail), 2 sim steps per frame, Neumann edges. Trigger (IntersectionObserver at 0.35 visibility, or first pointer entry) injects impulse h=1.0 at the trigger cell; a tile pops open when local |h| > 0.12 — scale 0.6→1 on a spring (k=90 s⁻², ζ=0.7) plus a rotateX-style vertical squash 35%→0 over 320ms with cubic-bezier(0.22,1,0.36,1); reveal = drawImage of that tile's source rect. Revealed tiles refract: source rect offset by (gradient of h) × 6px for a wet-glass shimmer. If the coarse wave dies below threshold before reaching the corners, stragglers are flush-scheduled outward from the trigger cell so the reveal always completes. After full reveal, pointermove injects impulse 0.35 at the cursor cell, throttled to one per frame. Pre-reveal tiles draw a border-token hairline, a faint foreground lift, and an accent-alpha shimmer proportional to |h| — every drawn color derived from --border/--accent/--foreground/--surface/--muted via a getComputedStyle probe at mount and re-derived live via MutationObserver on documentElement class changes, so both themes render correctly. The rAF loop is the sole writer (no React state on hot paths) and sleeps when max|h| < 0.004 AND max|v| < 0.004 and all tile springs are settled; it wakes on pointer or trigger and pauses offscreen via IntersectionObserver. IntersectionObserver, ResizeObserver (recompute grid, guard 0x0), MutationObserver, and all pointer listeners are torn down on unmount. Pure media surface: role=img with aria-label, no click behavior. Under prefers-reduced-motion tiles reveal instantly in a single paint with zero wave distortion, static image thereafter.

Tags
canvasmediarevealwave-simtilesrefractioncursorscroll-trigger