Scroll Defrost

Imageloud

Scroll-scrubbed defroster — a pinned full-bleed image behind rippled shower glass anneals from heavy frost with chromatic fringing to optical clarity, sealed by a specular sweep.

Install
npx shadcn add https://design.helpmarq.com/r/scroll-defrost.json
Source
registry/loud/scroll-defrost/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// FrostScrub — scroll is the defroster. A pinned full-bleed image sits behind
// a pane of rippled shower glass: heavily frosted at progress 0 with chromatic
// fringing on every ripple edge, annealing to optical clarity as a 300vh
// section scrubs. Fully reversible; a specular sweep crosses the pane as
// progress runs 0.92 → 1.0. Raw WebGL — one fullscreen quad, one fragment
// shader, zero deps. Direct-DOM rAF loop, no React state on the hot path,
// sleeps when |target − current| < 0.001 and wakes on scroll only.
// ---------------------------------------------------------------------------

const NOISE_SIZE = 256;
const LERP = 0.12; // rendered progress chases target at 0.12/frame
const SETTLE = 0.001;

// house sin-hash value noise on a periodic lattice, so the 256×256 normal-map
// texture tiles seamlessly under REPEAT wrap
function hashP(x: number, y: number, period: number) {
  const xi = ((x % period) + period) % period;
  const yi = ((y % period) + period) % period;
  const n = Math.sin(xi * 127.1 + yi * 311.7 + period * 0.917) * 43758.5453123;
  return n - Math.floor(n);
}
function vnoiseP(x: number, y: number, period: number) {
  const xi = Math.floor(x);
  const yi = Math.floor(y);
  const xf = x - xi;
  const yf = y - yi;
  const u = xf * xf * (3 - 2 * xf);
  const v = yf * yf * (3 - 2 * yf);
  const a = hashP(xi, yi, period);
  const b = hashP(xi + 1, yi, period);
  const c = hashP(xi, yi + 1, period);
  const d = hashP(xi + 1, yi + 1, period);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
// fx, fy in [0,1) across the tile; both octave frequencies divide the tile
function noise2(fx: number, fy: number) {
  return 0.65 * vnoiseP(fx * 8, fy * 8, 8) + 0.35 * vnoiseP(fx * 16, fy * 16, 16);
}

// 256×256 RGBA: normal.xy packed in RG (central differences, wrapped),
// ripple height in B — generated once on the CPU
function buildNoiseTexture() {
  const s = NOISE_SIZE;
  const height = new Float32Array(s * s);
  for (let y = 0; y < s; y++) {
    for (let x = 0; x < s; x++) {
      height[y * s + x] = noise2(x / s, y / s);
    }
  }
  const data = new Uint8Array(s * s * 4);
  for (let y = 0; y < s; y++) {
    const up = ((y + s - 1) % s) * s;
    const dn = ((y + 1) % s) * s;
    const row = y * s;
    for (let x = 0; x < s; x++) {
      const l = height[row + ((x + s - 1) % s)] ?? 0;
      const r = height[row + ((x + 1) % s)] ?? 0;
      const t = height[up + x] ?? 0;
      const b = height[dn + x] ?? 0;
      const nx = Math.max(-1, Math.min(1, (l - r) * 6));
      const ny = Math.max(-1, Math.min(1, (t - b) * 6));
      const o = (row + x) * 4;
      data[o] = Math.round((nx * 0.5 + 0.5) * 255);
      data[o + 1] = Math.round((ny * 0.5 + 0.5) * 255);
      data[o + 2] = Math.round((height[row + x] ?? 0) * 255);
      data[o + 3] = 255;
    }
  }
  return data;
}

// monochrome studio still (sphere on a lit wall, film grain) — the default
// subject when no src is provided, so the bare component is self-contained
function buildDefaultArt(): HTMLCanvasElement | null {
  const w = 900;
  const h = 1200;
  const c = document.createElement("canvas");
  c.width = w;
  c.height = h;
  const ctx = c.getContext("2d");
  if (!ctx) return null;

  const bg = ctx.createLinearGradient(0, 0, 0, h);
  bg.addColorStop(0, "#181818");
  bg.addColorStop(1, "#090909");
  ctx.fillStyle = bg;
  ctx.fillRect(0, 0, w, h);

  const glow = ctx.createRadialGradient(w * 0.5, h * 0.34, 0, w * 0.5, h * 0.34, h * 0.55);
  glow.addColorStop(0, "rgba(255,255,255,0.12)");
  glow.addColorStop(1, "rgba(255,255,255,0)");
  ctx.fillStyle = glow;
  ctx.fillRect(0, 0, w, h);

  const cx = w * 0.5;
  const cy = h * 0.45;
  const r = w * 0.3;

  ctx.save();
  ctx.translate(cx, cy + r * 1.32);
  ctx.scale(1, 0.22);
  const shadow = ctx.createRadialGradient(0, 0, 0, 0, 0, r * 1.15);
  shadow.addColorStop(0, "rgba(0,0,0,0.55)");
  shadow.addColorStop(1, "rgba(0,0,0,0)");
  ctx.fillStyle = shadow;
  ctx.beginPath();
  ctx.arc(0, 0, r * 1.15, 0, Math.PI * 2);
  ctx.fill();
  ctx.restore();

  const sphere = ctx.createRadialGradient(cx - r * 0.38, cy - r * 0.42, r * 0.08, cx, cy, r);
  sphere.addColorStop(0, "#f2f2f2");
  sphere.addColorStop(0.45, "#8f8f8f");
  sphere.addColorStop(0.8, "#2c2c2c");
  sphere.addColorStop(1, "#101010");
  ctx.fillStyle = sphere;
  ctx.beginPath();
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
  ctx.fill();

  const spec = ctx.createRadialGradient(cx - r * 0.42, cy - r * 0.48, 0, cx - r * 0.42, cy - r * 0.48, r * 0.22);
  spec.addColorStop(0, "rgba(255,255,255,0.95)");
  spec.addColorStop(1, "rgba(255,255,255,0)");
  ctx.fillStyle = spec;
  ctx.beginPath();
  ctx.arc(cx - r * 0.42, cy - r * 0.48, r * 0.22, 0, Math.PI * 2);
  ctx.fill();

  // film grain — Uint8ClampedArray clamps writes, no manual bounds needed
  const id = ctx.getImageData(0, 0, w, h);
  const d = id.data;
  for (let i = 0; i < d.length; i += 4) {
    const g = (Math.random() - 0.5) * 10;
    d[i] = (d[i] ?? 0) + g;
    d[i + 1] = (d[i + 1] ?? 0) + g;
    d[i + 2] = (d[i + 2] ?? 0) + g;
  }
  ctx.putImageData(id, 0, 0);
  return c;
}

const VERT = `
attribute vec2 aPos;
varying vec2 vUv;
void main() {
  vUv = aPos * 0.5 + 0.5;
  gl_Position = vec4(aPos, 0.0, 1.0);
}
`;

const FRAG = `
precision highp float;

varying vec2 vUv;

uniform sampler2D uTex;     // image
uniform sampler2D uNoise;   // tiling ripple normal map (RG = normal, B = height)
uniform vec2 uRes;          // pane size, CSS px
uniform vec2 uImgRes;       // image size, px
uniform float uDpr;
uniform float uProgress;    // 0 = frosted, 1 = annealed
uniform float uMaxOffset;   // refraction amplitude at full frost, px
uniform float uFrostRadius; // poisson scatter radius at full frost, px
uniform float uDispersion;  // chromatic step between R/G/B offset scales
uniform float uEdgeFrost;   // 1 = reduced-motion frost vignette at pane edges

// object-fit: cover mapping
vec2 coverUv(vec2 uv) {
  float ra = uRes.x / uRes.y;
  float ia = uImgRes.x / uImgRes.y;
  vec2 s = ra > ia ? vec2(1.0, ia / ra) : vec2(ra / ia, 1.0);
  return (uv - 0.5) * s + 0.5;
}

// one refracted sample with chromatic dispersion: R/G/B at offset scales
// 1.0 / 1.0+d / 1.0+2d — fringing lives on every ripple edge
vec3 glassTap(vec2 base, vec2 off) {
  float r = texture2D(uTex, coverUv(base + off)).r;
  float g = texture2D(uTex, coverUv(base + off * (1.0 + uDispersion))).g;
  float b = texture2D(uTex, coverUv(base + off * (1.0 + 2.0 * uDispersion))).b;
  return vec3(r, g, b);
}

void main() {
  vec2 uv = vUv;
  vec2 cssPx = gl_FragCoord.xy / uDpr;
  vec3 n = texture2D(uNoise, cssPx / 256.0).rgb * 2.0 - 1.0;

  float frost = 1.0 - uProgress;
  // reduced motion: faint frost confined to the pane edges at p = 1
  vec2 fromCenter = abs(uv - 0.5);
  float edge = smoothstep(0.30, 0.5, max(fromCenter.x, fromCenter.y));
  frost = clamp(max(frost, uEdgeFrost * edge * 0.35), 0.0, 1.0);

  float amp = pow(frost, 1.6);
  vec2 off = n.xy * amp * uMaxOffset / uRes;
  vec2 scatter = vec2(frost * uFrostRadius) / uRes;

  // 5-tap poisson frost scatter around the refracted lookup
  vec3 col = glassTap(uv, off);
  col += glassTap(uv + vec2(-0.94, -0.39) * scatter, off);
  col += glassTap(uv + vec2( 0.97, -0.19) * scatter, off);
  col += glassTap(uv + vec2(-0.37,  0.86) * scatter, off);
  col += glassTap(uv + vec2( 0.45,  0.61) * scatter, off);
  col *= 0.2;

  // frost lifts the pane toward white; ripple height catches a little light
  col = mix(col, vec3(0.82), amp * 0.22);
  col += n.z * amp * 0.06;

  // specular sweep: white band crosses the pane once as p runs 0.92 -> 1.0
  float sw = clamp((uProgress - 0.92) / 0.08, 0.0, 1.0);
  float axis = (uv.x + (1.0 - uv.y) * 0.4) / 1.4;
  float band = smoothstep(0.08, 0.0, abs(axis - mix(-0.15, 1.15, sw)));
  col += vec3(band * 0.35);

  gl_FragColor = vec4(col, 1.0);
}
`;

function createProgram(gl: WebGLRenderingContext, vert: string, frag: string) {
  const compile = (type: number, source: string) => {
    const sh = gl.createShader(type);
    if (!sh) return null;
    gl.shaderSource(sh, source);
    gl.compileShader(sh);
    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
      gl.deleteShader(sh);
      return null;
    }
    return sh;
  };
  const vs = compile(gl.VERTEX_SHADER, vert);
  const fs = compile(gl.FRAGMENT_SHADER, frag);
  if (!vs || !fs) return null;
  const program = gl.createProgram();
  if (!program) return null;
  gl.attachShader(program, vs);
  gl.attachShader(program, fs);
  gl.linkProgram(program);
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    gl.deleteProgram(program);
    return null;
  }
  return program;
}

export function FrostScrub({
  src,
  alt = "Image annealing from frosted glass to optical clarity",
  caption = "SCROLL TO ANNEAL",
  maxOffset = 42,
  frostRadius = 10,
  dispersion = 0.012,
  className = "",
  onProgress,
}: {
  /** image url; omit for a generated monochrome studio still */
  src?: string;
  alt?: string;
  /** mono caption on the side rail */
  caption?: string;
  /** px of refraction offset at full frost */
  maxOffset?: number;
  /** px radius of the 5-tap frost scatter at full frost */
  frostRadius?: number;
  /** chromatic dispersion step between R/G/B offset scales */
  dispersion?: number;
  className?: string;
  /** rendered (lerped) progress 0–1, called from the rAF loop */
  onProgress?: (p: number) => void;
}) {
  const sectionRef = useRef<HTMLElement>(null);
  const paneRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const imgRef = useRef<HTMLImageElement>(null);
  const barRef = useRef<HTMLSpanElement>(null);
  const pctRef = useRef<HTMLSpanElement>(null);
  const onProgressRef = useRef(onProgress);

  useEffect(() => {
    onProgressRef.current = onProgress;
  }, [onProgress]);

  useEffect(() => {
    const section = sectionRef.current;
    const pane = paneRef.current;
    const canvas = canvasRef.current;
    const img = imgRef.current;
    const bar = barRef.current;
    const pct = pctRef.current;
    if (!section || !pane || !canvas || !img || !bar || !pct) return;

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

    let disposed = false;
    let raf = 0;
    let target = 0;
    let current = 0;

    const readout = (p: number) => {
      pct.textContent = `${String(Math.round(p * 100)).padStart(3, "0")}%`;
      bar.style.transform = `scaleY(${p.toFixed(4)})`;
      onProgressRef.current?.(p);
    };

    let art: HTMLCanvasElement | null = null;
    const getArt = () => (art ??= buildDefaultArt());

    // fallback when WebGL is unavailable: CSS blur on a plain <img>
    let usingFallback = false;
    const enterFallback = () => {
      usingFallback = true;
      canvas.classList.add("hidden");
      img.classList.remove("hidden");
      if (src) {
        img.src = src;
      } else {
        const a = getArt();
        if (a) img.src = a.toDataURL("image/png");
      }
    };

    let render: ((p: number) => void) | null = null;
    let resize: (() => void) | null = null;

    const gl = canvas.getContext("webgl", {
      alpha: false,
      antialias: false,
      depth: false,
      stencil: false,
    });

    const setup = (): boolean => {
      if (!gl) return false;
      const program = createProgram(gl, VERT, FRAG);
      if (!program) return false;
      gl.useProgram(program);

      const buf = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, buf);
      gl.bufferData(
        gl.ARRAY_BUFFER,
        new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),
        gl.STATIC_DRAW
      );
      const aPos = gl.getAttribLocation(program, "aPos");
      if (aPos < 0) return false;
      gl.enableVertexAttribArray(aPos);
      gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);

      const u = (name: string) => gl.getUniformLocation(program, name);
      const uRes = u("uRes");
      const uImgRes = u("uImgRes");
      const uDpr = u("uDpr");
      const uProgress = u("uProgress");
      gl.uniform1f(u("uMaxOffset"), maxOffset);
      gl.uniform1f(u("uFrostRadius"), frostRadius);
      gl.uniform1f(u("uDispersion"), dispersion);
      gl.uniform1f(u("uEdgeFrost"), reduced ? 1 : 0);
      gl.uniform1i(u("uTex"), 0);
      gl.uniform1i(u("uNoise"), 1);

      // ripple normal map on unit 1 — REPEAT so the 256 tile wraps seamlessly
      gl.activeTexture(gl.TEXTURE1);
      gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
      gl.texImage2D(
        gl.TEXTURE_2D,
        0,
        gl.RGBA,
        NOISE_SIZE,
        NOISE_SIZE,
        0,
        gl.RGBA,
        gl.UNSIGNED_BYTE,
        buildNoiseTexture()
      );
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);

      // image on unit 0 — NPOT-safe params, dark placeholder until pixels land
      gl.activeTexture(gl.TEXTURE0);
      const tex = gl.createTexture();
      gl.bindTexture(gl.TEXTURE_2D, tex);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
      gl.texImage2D(
        gl.TEXTURE_2D,
        0,
        gl.RGBA,
        1,
        1,
        0,
        gl.RGBA,
        gl.UNSIGNED_BYTE,
        new Uint8Array([23, 23, 23, 255])
      );
      gl.uniform2f(uImgRes, 1, 1);
      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);

      const uploadSource = (source: TexImageSource, w: number, h: number) => {
        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, tex);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
        gl.uniform2f(uImgRes, w, h);
      };

      if (src) {
        const image = new Image();
        image.crossOrigin = "anonymous";
        image.onload = () => {
          if (disposed) return;
          uploadSource(image, image.naturalWidth, image.naturalHeight);
          render?.(current);
        };
        image.onerror = () => {
          if (disposed) return;
          console.error(`FrostScrub: failed to load src "${src}", falling back to default art`);
          const a = getArt();
          if (a) uploadSource(a, a.width, a.height);
          render?.(current);
        };
        image.src = src;
      } else {
        const a = getArt();
        if (a) uploadSource(a, a.width, a.height);
      }

      resize = () => {
        const w = Math.max(1, pane.clientWidth);
        const h = Math.max(1, pane.clientHeight);
        // Homepage/catalog cards render this exact DOM inside a fixed
        // 1440×900 iframe that a *parent-document* CSS transform shrinks to a
        // ~380px-wide thumbnail — invisible to this document's own layout, so
        // `pane.clientWidth` still reads 1440 and a dpr-only buffer would
        // shade the full 5-tap fragment shader at full desktop resolution for
        // pixels nobody can resolve at thumbnail size. `[data-autoplay-root]`
        // (see autoplay-driver.tsx) is stamped only on that exact route
        // shape, so it doubles as the "I'm a card" signal here: cap the
        // buffer to what a thumbnail can actually show. The real
        // `/preview/<name>` reference page (what scripts/verify.ts shoots)
        // never gets this attribute, so its resolution — and look — is
        // unchanged.
        const isCard = !!pane.closest("[data-autoplay-root]");
        const dpr = isCard
          ? Math.min(0.6, window.devicePixelRatio || 1)
          : Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.max(1, Math.round(w * dpr));
        canvas.height = Math.max(1, Math.round(h * dpr));
        gl.viewport(0, 0, canvas.width, canvas.height);
        gl.uniform2f(uRes, w, h);
        gl.uniform1f(uDpr, dpr);
      };
      render = (p: number) => {
        gl.uniform1f(uProgress, p);
        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
      };
      resize();
      return true;
    };

    if (!setup()) enterFallback();

    const drawFrame = (p: number) => {
      if (usingFallback) {
        img.style.filter = p >= 0.999 ? "none" : `blur(${(12 * (1 - p)).toFixed(2)}px)`;
      } else {
        render?.(p);
      }
      readout(p);
    };

    // reduced motion: one static frame at p = 1 — clear image, the shader's
    // uEdgeFrost vignette keeps a faint frost border. No loop, no listener.
    if (reduced) {
      if (usingFallback) img.style.filter = "none";
      drawFrame(1);
      const ro = new ResizeObserver(() => {
        resize?.();
        drawFrame(1);
      });
      ro.observe(pane);
      return () => {
        disposed = true;
        ro.disconnect();
        gl?.getExtension("WEBGL_lose_context")?.loseContext();
      };
    }

    const progressFromScroll = () => {
      const rect = section.getBoundingClientRect();
      const span = rect.height - window.innerHeight;
      if (span <= 1) return 1;
      return Math.min(1, Math.max(0, -rect.top / span));
    };

    const loop = () => {
      current += (target - current) * LERP;
      if (Math.abs(target - current) < SETTLE) {
        current = target;
        drawFrame(current);
        raf = 0; // sleep — wakes on scroll only
        return;
      }
      drawFrame(current);
      raf = requestAnimationFrame(loop);
    };
    const wake = () => {
      if (!raf) raf = requestAnimationFrame(loop);
    };
    const onScroll = () => {
      target = progressFromScroll();
      wake();
    };

    target = progressFromScroll();
    current = target;
    drawFrame(current);

    const ro = new ResizeObserver(() => {
      resize?.();
      target = progressFromScroll();
      drawFrame(current);
      wake();
    });
    ro.observe(pane);
    window.addEventListener("scroll", onScroll, { passive: true });

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      raf = 0;
      window.removeEventListener("scroll", onScroll);
      ro.disconnect();
      gl?.getExtension("WEBGL_lose_context")?.loseContext();
    };
    // onProgress intentionally excluded — delivered via onProgressRef so an
    // unmemoized inline callback can't tear down the GL setup every render.
  }, [src, maxOffset, frostRadius, dispersion]);

  return (
    <section
      ref={sectionRef}
      className={`relative h-[300vh] bg-background motion-reduce:h-screen ${className}`}
    >
      <div
        ref={paneRef}
        role="img"
        aria-label={alt}
        className="sticky top-0 h-screen w-full overflow-hidden bg-background"
      >
        <canvas ref={canvasRef} aria-hidden className="absolute inset-0 h-full w-full" />
        {/* fallback surface — revealed only when WebGL is unavailable */}
        <img
          ref={imgRef}
          alt=""
          aria-hidden
          className="absolute inset-0 hidden h-full w-full object-cover"
        />
        {/* side rail: caption, progress track, live percent — direct-DOM updates */}
        <div
          aria-hidden
          className="pointer-events-none absolute inset-y-0 right-4 z-10 flex flex-col items-center justify-center gap-4 font-mono md:right-8"
        >
          <span
            className="text-[10px] uppercase tracking-[0.3em] text-muted"
            style={{ writingMode: "vertical-rl" }}
          >
            {caption}
          </span>
          <span className="relative h-24 w-px overflow-hidden bg-foreground/20">
            <span
              ref={barRef}
              className="absolute inset-0 origin-top bg-foreground"
              style={{ transform: "scaleY(0)" }}
            />
          </span>
          <span ref={pctRef} className="text-xs tabular-nums text-foreground">
            000%
          </span>
        </div>
      </div>
    </section>
  );
}
Use when

a pinned scroll-scrubbed WebGL shader that anneals a full-bleed image from frosted shower glass to clarity as you scroll; use to reveal a real photo/image, not an abstract or generative scene.

Build spec

Build a pinned scroll-scrub frosted-glass reveal: a 300vh section holds a position:sticky h-screen pane; behind a rippled shower-glass shader sits a full-bleed image (prop src, else a monochrome studio still — sphere, wall glow, ground shadow, film grain — generated once on an offscreen canvas so the bare component is network-free). Raw WebGL 1, zero deps: one fullscreen triangle-strip quad and a single fragment shader. A 256x256 seamlessly-tiling RGBA noise texture is generated once on the CPU from two octaves of periodic sin-hash value noise (house noise2 on a wrapped lattice); central-difference gradients pack a normal map into RG with ripple height in B, sampled in the shader at gl_FragCoord in CSS px over REPEAT wrap. Refraction offset = normal.xy * pow(1-p, 1.6) * 42px; chromatic dispersion samples R/G/B at offset scales 1.0 / 1.012 / 1.024 so fringing lives on every ripple edge; frost scatter = 5-tap Poisson blur at radius (1-p)*10px; frost also lifts the pane toward white (mix 0.22 at full frost) with ripple-height shading; a white specular band (smoothstep width 0.08 along a diagonal UV axis) crosses the pane exactly once as p runs 0.92 to 1.0. Scroll model: a passive window scroll listener writes target progress (-rect.top / (sectionHeight - viewportHeight), clamped 0-1) to a closure variable; a direct-DOM rAF loop lerps rendered progress toward it at 0.12/frame, snaps and sleeps when |target - current| < 0.001, and wakes on scroll only — no React state anywhere on the hot path, fully reversible in both directions. Overlay side rail in font-mono: vertical-rl caption SCROLL TO ANNEAL, a 1px track filled via scaleY transform, and a zero-padded live percent, all updated by textContent/style writes inside the same loop (plus an optional onProgress callback). If WebGL or shader compile is unavailable, fall back to a plain object-cover img with CSS blur(12px * (1-p)) driven by the same lerp. Under prefers-reduced-motion render a single shader frame at p=1 — clear image with a faint frost vignette confined to the pane edges via an edge-frost uniform — and collapse the section to one viewport with no listeners.

Tags
scrollwebglshaderimagerefractionglasshero