Canvas

Ambient Canvas

A full-viewport fragment shader — a halftone dot grid colorized by a domain-warped FBM noise field (churning, water-like blobs rather than straight bands), alpha-blended at low opacity for a retro-CRT/static look. Replaced the earlier pointer-field particle grid (2026-07-05, owner's call) with a single fullscreen quad + ShaderMaterial — cheaper too, since it drops the old O(n^2) per-frame line-connect pass. Theme-independent by design: always the same rainbow palette, low opacity so it stays ambient instead of fighting page content. Pointer interactivity came back the same day as a brush trail: the existing dot grid morphs — same cells, same pitch — into black ASCII glyphs (ring/cross/plus/square) only where the pointer actually moves, painted via a ping-pong feedback buffer with an organic, noise-jittered width. The stamp itself is snapped to the same dot grid with a hard edge (no anti-aliasing) — still a round silhouette, but stepped/blocky at the edges since it's built from grid cells rather than smooth math, matching the retro grid's pixel language. The trail's continuous decay is quantized into 3 discrete steps before driving color/shape, so a stroke fades out in 3 visible layers instead of one smooth continuous fade. A stationary pointer paints nothing new — the existing stroke just steps through its fade-out.

Preview

Code

src/components/canvas/AmbientCanvas.tsx
"use client";

import { useEffect, useRef } from "react";

/**
 * Ambient backdrop — retro-CRT / halftone-dot shader, replacing the
 * pointer-field particle grid (see docs/roadmap.md Phase 3 for the prior
 * version). Full-viewport fragment shader instead of a per-frame JS particle
 * loop, so it's actually cheaper than what it replaced (no O(n^2) line-connect
 * pass). Three.js is still loaded dynamically so pages that never mount this
 * component don't pay for it.
 *
 * Deliberately theme-independent (always the same rainbow dot palette,
 * alpha-blended at low opacity) rather than tied to --color-primary/secondary
 * — the reference is a fixed CRT-static look, not a brand recolor, and low
 * opacity keeps it ambient instead of fighting page content in light mode.
 *
 * Glyph hover, brush + stepped-fade version (2026-07-05, fifth iteration):
 * back to a paint-on-move brush trail (a ping-pong feedback buffer — see
 * TRAIL_FRAGMENT_SHADER) with an organic, noise-jittered width so the stroke
 * itself reads as a brush, not a circle. Painted cells morph into black
 * ASCII glyphs (ring/cross/plus/square); the trail's continuous decay is
 * quantized into 3 discrete steps (`floor(trail * 3.0) / 3.0`) before it
 * drives color/shape, so a stroke fades out in 3 visible layers instead of
 * one smooth continuous fade.
 * Earlier iterations, in order: a separate monochrome overlay (looked
 * disconnected from the retro effect); a same-grid instantaneous single-
 * radius spotlight; the same brush trail as here but with a smooth
 * (non-stepped) fade; a static circular ripple with 3 rings fading IN on
 * hover-enter (rejected — needed to stay brush-shaped and fade OUT).
 */

const DOT_PX = 5.5;
const OPACITY = 0.42;
const BRUSH_RADIUS = 0.01;
const TRAIL_DECAY = 0.97;
const DPR_MIN = 1;
const DPR_MAX = 1.75;
const FPS_CAP = 60;
const RESIZE_DEBOUNCE_MS = 150;

const VERTEX_SHADER = `
  varying vec2 vUv;
  void main() {
    vUv = uv;
    gl_Position = vec4(position, 1.0);
  }
`;

// Offscreen pass: fades the previous trail, paints a new segment stamp when
// the pointer has moved this frame. Output lives in the red channel.
const TRAIL_FRAGMENT_SHADER = `
  precision mediump float;
  varying vec2 vUv;
  uniform sampler2D uPrevTrail;
  uniform vec2 uResolution;
  uniform vec2 uPointer;
  uniform vec2 uPrevPointer;
  uniform float uBrushRadius;
  uniform float uDecay;
  uniform float uStampStrength;
  uniform float uTime;

  float hash(vec2 p) {
    p = fract(p * vec2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
  }

  float noise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    float a = hash(i);
    float b = hash(i + vec2(1.0, 0.0));
    float c = hash(i + vec2(0.0, 1.0));
    float d = hash(i + vec2(1.0, 1.0));
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
  }

  void main() {
    float decayed = texture2D(uPrevTrail, vUv).r * uDecay;

    // Snap the sample position to the same DOT_PX grid the main shader
    // draws on, so the stamp is blocky/per-cell (retro pixel grid) instead
    // of a smooth anti-aliased shape floating independently of it.
    vec2 gridRes = uResolution / DOT_PX_PLACEHOLDER;
    vec2 snappedUv = (floor(vUv * gridRes) + 0.5) / gridRes;

    vec2 aspect = vec2(uResolution.x / uResolution.y, 1.0);
    vec2 a = (snappedUv - uPrevPointer) * aspect;
    vec2 ba = (uPointer - uPrevPointer) * aspect;
    float h = clamp(dot(a, ba) / max(dot(ba, ba), 1e-5), 0.0, 1.0);
    // Euclidean distance — the silhouette still reads as round, just
    // stepped/blocky at the edges because it's sampled on the snapped grid.
    vec2 diff = a - ba * h;
    float d = length(diff);

    // Organic brush: jitter the effective radius along the stroke instead of
    // a constant-width tube — blocky steps in time (floor on uTime) so it
    // reads as clustered dense/sparse sections, like a real brush's
    // pressure varying, not a uniform bar.
    float widthNoise = noise(vec2(h * 6.0, floor(uTime * 8.0)));
    float radius = uBrushRadius * mix(0.5, 1.6, widthNoise);
    float stamp = step(d, radius) * uStampStrength;

    gl_FragColor = vec4(max(decayed, stamp), 0.0, 0.0, 1.0);
  }
`.replace("DOT_PX_PLACEHOLDER", DOT_PX.toFixed(1));

const MAIN_FRAGMENT_SHADER = `
  precision mediump float;
  varying vec2 vUv;
  uniform float uTime;
  uniform vec2 uResolution;
  uniform float uOpacity;
  uniform sampler2D uTrail;

  vec3 hsv2rgb(vec3 c) {
    vec3 p = abs(fract(c.xxx + vec3(0.0, 2.0 / 3.0, 1.0 / 3.0)) * 6.0 - 3.0);
    vec3 rgb = clamp(p - 1.0, 0.0, 1.0);
    return c.z * mix(vec3(1.0), rgb, c.y);
  }

  float hash(vec2 p) {
    p = fract(p * vec2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
  }

  float noise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    float a = hash(i);
    float b = hash(i + vec2(1.0, 0.0));
    float c = hash(i + vec2(0.0, 1.0));
    float d = hash(i + vec2(1.0, 1.0));
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
  }

  float fbm(vec2 p) {
    float value = 0.0;
    float amp = 0.5;
    for (int i = 0; i < 4; i++) {
      value += amp * noise(p);
      p *= 2.0;
      amp *= 0.5;
    }
    return value;
  }

  // One of four glyphs (ring "o", cross "x", plus "+", square) per grid
  // cell, picked by a hash of the cell index — the ASCII-mask hover reference.
  float glyphMask(vec2 cellIndex, vec2 local) {
    float pick = hash(cellIndex);
    float r = length(local);

    float ring = smoothstep(0.09, 0.03, abs(r - 0.30));

    float dx = abs(local.x - local.y);
    float dy = abs(local.x + local.y);
    float cross = smoothstep(0.09, 0.03, min(dx, dy)) * step(r, 0.36);

    float pd = min(abs(local.x), abs(local.y));
    float bound = max(abs(local.x), abs(local.y));
    float plus = smoothstep(0.09, 0.03, pd) * step(bound, 0.34);

    float square = smoothstep(0.34, 0.30, bound) * (1.0 - smoothstep(0.18, 0.22, bound));

    if (pick < 0.25) return ring;
    if (pick < 0.5) return cross;
    if (pick < 0.75) return plus;
    return square;
  }

  void main() {
    vec2 gridRes = uResolution / DOT_PX_PLACEHOLDER;
    vec2 cellIndex = floor(vUv * gridRes);
    vec2 cellUv = cellIndex / gridRes;
    vec2 cellLocal = fract(vUv * gridRes) - 0.5;

    // domain-warped FBM — a noise field distorting another noise field's
    // sample coordinates, so the result reads as irregular churning water
    // instead of straight/regular bands.
    vec2 flow = cellUv * 2.6 + vec2(uTime * 0.05, -uTime * 0.07);
    vec2 warp = vec2(fbm(flow + 1.7), fbm(flow + 8.3));
    float n = fbm(flow + warp * 1.6);

    float gray = mix(0.15, 0.9, n);
    vec3 color = vec3(gray);

    // Base state: every cell is a plain dot (the retro halftone look).
    float baseDot = smoothstep(0.42, 0.28, length(cellLocal));

    // Brush trail fades out in 3 discrete layers (quantized), not one smooth
    // continuous fade — a stroke visibly drops through 3 stages as it decays.
    float trail = texture2D(uTrail, vUv).r;
    float steppedTrail = floor(trail * 3.0) / 3.0;

    float glyphShape = glyphMask(cellIndex, cellLocal);
    float shape = mix(baseDot, glyphShape, steppedTrail);
    vec3 finalColor = mix(color, vec3(0.0), steppedTrail);

    gl_FragColor = vec4(finalColor, shape * mix(uOpacity, 1.0, steppedTrail * 0.6));
  }
`.replace("DOT_PX_PLACEHOLDER", DOT_PX.toFixed(1));

export function AmbientCanvas({
  className = "pointer-events-none fixed inset-x-0 top-0 -z-10 h-dvh w-full",
}: { className?: string } = {}) {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const isLowPower = (navigator.hardwareConcurrency || 4) <= 2;
    if (isLowPower || typeof window.WebGLRenderingContext === "undefined") {
      return; // low-power fallback: no canvas mounted, static gradient backdrop shows through
    }

    let disposed = false;
    let raf = 0;
    let paused = false;
    let lastFrameAt = 0;
    const startedAt = performance.now();
    let resizeTimer: ReturnType<typeof setTimeout> | undefined;

    import("three")
      .then((THREE) => {
        if (disposed || !container) return;

        let width = container.clientWidth;
        let height = container.clientHeight;

        const renderer = new THREE.WebGLRenderer({ alpha: true, antialias: false });
        renderer.setPixelRatio(Math.min(Math.max(window.devicePixelRatio, DPR_MIN), DPR_MAX));
        renderer.setSize(width, height);
        container.appendChild(renderer.domElement);

        const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

        const mainMaterial = new THREE.ShaderMaterial({
          vertexShader: VERTEX_SHADER,
          fragmentShader: MAIN_FRAGMENT_SHADER,
          transparent: true,
          uniforms: {
            uTime: { value: 0 },
            uResolution: { value: new THREE.Vector2(width, height) },
            uOpacity: { value: OPACITY },
            uTrail: { value: null },
          },
        });
        const scene = new THREE.Scene();
        const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), mainMaterial);
        scene.add(quad);

        const trailMaterial = new THREE.ShaderMaterial({
          vertexShader: VERTEX_SHADER,
          fragmentShader: TRAIL_FRAGMENT_SHADER,
          uniforms: {
            uPrevTrail: { value: null },
            uResolution: { value: new THREE.Vector2(width, height) },
            uPointer: { value: new THREE.Vector2(-10, -10) },
            uPrevPointer: { value: new THREE.Vector2(-10, -10) },
            uBrushRadius: { value: BRUSH_RADIUS },
            uDecay: { value: TRAIL_DECAY },
            uStampStrength: { value: 0 },
            uTime: { value: 0 },
          },
        });
        const trailScene = new THREE.Scene();
        const trailQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), trailMaterial);
        trailScene.add(trailQuad);

        function makeTrailTarget(w: number, h: number) {
          return new THREE.WebGLRenderTarget(Math.max(1, w), Math.max(1, h), {
            minFilter: THREE.LinearFilter,
            magFilter: THREE.LinearFilter,
          });
        }
        let trailTargets = [makeTrailTarget(width, height), makeTrailTarget(width, height)];
        let writeIndex = 0;
        for (const target of trailTargets) {
          renderer.setRenderTarget(target);
          renderer.clear();
        }
        renderer.setRenderTarget(null);

        let hasPointer = false;
        let movedThisFrame = false;
        const pointerCurrent = new THREE.Vector2(-10, -10);

        function renderFrame(now: number) {
          const t = (now - startedAt) / 1000;
          mainMaterial.uniforms.uTime.value = t;
          trailMaterial.uniforms.uTime.value = t;

          const readTarget = trailTargets[1 - writeIndex];
          const writeTarget = trailTargets[writeIndex];

          trailMaterial.uniforms.uPrevTrail.value = readTarget.texture;
          trailMaterial.uniforms.uStampStrength.value = movedThisFrame ? 1 : 0;

          renderer.setRenderTarget(writeTarget);
          renderer.render(trailScene, camera);
          renderer.setRenderTarget(null);

          mainMaterial.uniforms.uTrail.value = writeTarget.texture;
          renderer.render(scene, camera);

          trailMaterial.uniforms.uPrevPointer.value.copy(pointerCurrent);
          movedThisFrame = false;
          writeIndex = 1 - writeIndex;
        }

        function loop(now: number) {
          if (disposed || paused) return;
          raf = requestAnimationFrame(loop);
          const elapsed = now - lastFrameAt;
          if (elapsed < 1000 / FPS_CAP) return;
          lastFrameAt = now;
          renderFrame(now);
        }

        if (prefersReducedMotion) {
          renderFrame(performance.now()); // static base dot grid — no brush interactivity
        } else {
          raf = requestAnimationFrame(loop);
        }

        function handleVisibility() {
          if (document.hidden) {
            paused = true;
            cancelAnimationFrame(raf);
          } else if (!prefersReducedMotion) {
            paused = false;
            lastFrameAt = 0;
            raf = requestAnimationFrame(loop);
          }
        }
        document.addEventListener("visibilitychange", handleVisibility);

        function onPointerMove(e: PointerEvent) {
          const rect = container!.getBoundingClientRect();
          const x = (e.clientX - rect.left) / rect.width;
          const y = 1.0 - (e.clientY - rect.top) / rect.height;
          if (!hasPointer) {
            // first move after entering — snap prevPointer here too, so the
            // first stamp is a point, not a streak from the old off-screen sentinel.
            trailMaterial.uniforms.uPrevPointer.value.set(x, y);
            hasPointer = true;
          }
          pointerCurrent.set(x, y);
          trailMaterial.uniforms.uPointer.value.set(x, y);
          movedThisFrame = true;
        }
        function onPointerLeave() {
          hasPointer = false;
          movedThisFrame = false;
        }
        window.addEventListener("pointermove", onPointerMove, { passive: true });
        window.addEventListener("pointerleave", onPointerLeave, { passive: true });

        function handleResize() {
          clearTimeout(resizeTimer);
          resizeTimer = setTimeout(() => {
            if (disposed || !container) return;
            width = container.clientWidth;
            height = container.clientHeight;
            renderer.setSize(width, height);
            mainMaterial.uniforms.uResolution.value.set(width, height);
            trailMaterial.uniforms.uResolution.value.set(width, height);
            for (const target of trailTargets) target.dispose();
            trailTargets = [makeTrailTarget(width, height), makeTrailTarget(width, height)];
            for (const target of trailTargets) {
              renderer.setRenderTarget(target);
              renderer.clear();
            }
            renderer.setRenderTarget(null);
            writeIndex = 0;
            if (prefersReducedMotion) renderFrame(performance.now());
          }, RESIZE_DEBOUNCE_MS);
        }
        window.addEventListener("resize", handleResize);
        // iOS Safari: window "resize" doesn't reliably fire when the bottom
        // toolbar shows/hides on scroll (the CSS box already tracks it via
        // h-dvh, but the <canvas> element's pixel size needs an explicit
        // resize call) — visualViewport does fire for that transition.
        window.visualViewport?.addEventListener("resize", handleResize);

        (container as HTMLDivElement & { __cleanup?: () => void }).__cleanup = () => {
          window.removeEventListener("resize", handleResize);
          window.visualViewport?.removeEventListener("resize", handleResize);
          window.removeEventListener("pointermove", onPointerMove);
          window.removeEventListener("pointerleave", onPointerLeave);
          document.removeEventListener("visibilitychange", handleVisibility);
          clearTimeout(resizeTimer);
          cancelAnimationFrame(raf);
          quad.geometry.dispose();
          mainMaterial.dispose();
          trailQuad.geometry.dispose();
          trailMaterial.dispose();
          for (const target of trailTargets) target.dispose();
          renderer.dispose();
          renderer.domElement.remove();
        };
      })
      .catch(() => {
        // WebGL/three failed to initialize — leave the static gradient as the only backdrop
      });

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      const el = container as HTMLDivElement & { __cleanup?: () => void };
      el.__cleanup?.();
    };
  }, []);

  return <div ref={containerRef} aria-hidden className={className} />;
}

Installation

Usage
import { AmbientCanvas } from "@/components/canvas/AmbientCanvas";

// full-viewport backdrop (the marketing layout's actual usage)
<AmbientCanvas />

// embedded in a bounded container — pass className to override the default fixed/inset-0
<div className="relative h-64 overflow-hidden rounded-lg">
  <AmbientCanvas className="absolute inset-0" />
</div>

Props

PropTypeDefaultDescription
classNamestring"pointer-events-none fixed inset-0 -z-10"Overrides the wrapper div's classes — pass an `absolute inset-0` variant to embed it inside a bounded container instead of the full viewport.

Accessibility

Renders an `aria-hidden` div — purely decorative. Respects prefers-reduced-motion (renders exactly one static frame, no animation loop) and pauses via the Page Visibility API when the tab is hidden. Doesn't mount at all on low-power devices (`navigator.hardwareConcurrency <= 2`) or without WebGL — the static gradient backdrop shows through instead.

web design.