Skip to content

Dithered Waves

Bayer-dithered waves in black on white.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/dithered-waves.json

Manual

  1. Install the dependencies

    npm install clsx tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/color.ts
    export type Vec3 = [r: number, g: number, b: number];
    
    /**
     * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels.
     * Anything else falls back to black, so a typo never crashes a shader.
     */
    export function hexToRgb(hex: string): Vec3 {
      let value = hex.trim().replace(/^#/, "");
      if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join("");
      if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0];
      const n = Number.parseInt(value.slice(0, 6), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    
    /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function hexToVec3(hex: string): Vec3 {
      const [r, g, b] = hexToRgb(hex);
      return [r / 255, g / 255, b / 255];
    }
    
    /**
     * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as
     * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables
     * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result
     * instead of calling this every frame. Browser only; unreadable values fall back to black.
     */
    export function cssColorToRgb(color: string, element: Element): Vec3 {
      const value = color.trim();
      if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value);
      // Children of a canvas still get computed styles, but a regular parent is the safer host.
      const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element;
      const probe = document.createElement("span");
      probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden";
      probe.style.color = value;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes.
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0];
      ctx.fillStyle = "#000";
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b];
    }
    
    /**
     * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or
     * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme.
     * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe.
     */
    export function observeTheme(onChange: () => void): () => void {
      const observer = new MutationObserver(onChange);
      const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
      observer.observe(document.documentElement, options);
      if (document.body) observer.observe(document.body, options);
      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      scheme.addEventListener("change", onChange);
      return () => {
        observer.disconnect();
        scheme.removeEventListener("change", onChange);
      };
    }
    
    /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function cssColorToVec3(color: string, element: Element): Vec3 {
      const [r, g, b] = cssColorToRgb(color, element);
      return [r / 255, g / 255, b / 255];
    }
    
    const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
    const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
    
    /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */
    export function rgbToOklch([r, g, b]: Vec3): Vec3 {
      const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
      const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
      const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
      const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
      const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
      const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
      const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
      return [L, Math.hypot(A, B), Math.atan2(B, A)];
    }
    
    /** OKLCH back to 0–255 sRGB, clamped into gamut. */
    export function oklchToRgb([L, C, h]: Vec3): Vec3 {
      const A = C * Math.cos(h);
      const B = C * Math.sin(h);
      const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
      const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
      const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
      const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
      return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3;
    }
    
    /**
     * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep
     * their chroma instead of turning muddy the way an RGB mix does.
     */
    export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 {
      const [L1, C1, h1] = rgbToOklch(a);
      const [L2, C2, h2] = rgbToOklch(b);
      // A near-gray end has no meaningful hue: borrow the other end's.
      const ha = C1 < 0.02 ? h2 : h1;
      const hb = C2 < 0.02 ? h1 : h2;
      let dh = hb - ha;
      if (dh > Math.PI) dh -= Math.PI * 2;
      if (dh < -Math.PI) dh += Math.PI * 2;
      return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]);
    }
    lib/webgl/noise.glsl.ts
    /**
     * Shared GLSL ES 3.00 snippets. Concatenate the ones a shader needs before its main():
     *
     *   const fragment = `${GLSL_HASH}${GLSL_SIMPLEX}${GLSL_FBM} void main() { … }`;
     *
     * GLSL_SIMPLEX needs GLSL_HASH, and GLSL_FBM needs GLSL_SIMPLEX.
     */
    
    /** Sine-free hashes: stable across GPUs, no visible banding. */
    export const GLSL_HASH = /* glsl */ `
    float hash12(vec2 p) {
      vec3 p3 = fract(vec3(p.xyx) * 0.1031);
      p3 += dot(p3, p3.yzx + 33.33);
      return fract((p3.x + p3.y) * p3.z);
    }
    
    vec2 hash22(vec2 p) {
      vec3 p3 = fract(vec3(p.xyx) * vec3(0.1031, 0.1030, 0.0973));
      p3 += dot(p3, p3.yzx + 33.33);
      return fract((p3.xx + p3.yz) * p3.zy);
    }
    `;
    
    /** 2D simplex noise in roughly [-1, 1]. */
    export const GLSL_SIMPLEX = /* glsl */ `
    vec2 simplexGradient(vec2 cell) {
      float angle = hash12(cell) * 6.28318530718;
      return vec2(cos(angle), sin(angle));
    }
    
    float snoise(vec2 p) {
      const float F2 = 0.36602540378; // (sqrt(3) - 1) / 2
      const float G2 = 0.21132486540; // (3 - sqrt(3)) / 6
      vec2 cell = floor(p + (p.x + p.y) * F2);
      vec2 a = p - cell + (cell.x + cell.y) * G2;
      vec2 step1 = a.x > a.y ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
      vec2 b = a - step1 + G2;
      vec2 c = a - 1.0 + 2.0 * G2;
      vec3 falloff = max(0.5 - vec3(dot(a, a), dot(b, b), dot(c, c)), 0.0);
      falloff *= falloff;
      falloff *= falloff;
      vec3 contribution = vec3(
        dot(a, simplexGradient(cell)),
        dot(b, simplexGradient(cell + step1)),
        dot(c, simplexGradient(cell + 1.0))
      );
      return 70.0 * dot(falloff, contribution);
    }
    `;
    
    /** Fractal Brownian motion over simplex noise; octaves is clamped to 1–8. Roughly [-1, 1]. */
    export const GLSL_FBM = /* glsl */ `
    float fbm(vec2 p, int octaves) {
      const mat2 rotate = mat2(0.8, 0.6, -0.6, 0.8);
      float sum = 0.0;
      float amplitude = 0.5;
      for (int i = 0; i < 8; i++) {
        if (i >= octaves) break;
        sum += amplitude * snoise(p);
        p = rotate * p * 2.03 + 17.1;
        amplitude *= 0.5;
      }
      return sum;
    }
    `;
    
    /** Ordered (Bayer) dither thresholds in [0, 1) for 2×2, 4×4 and 8×8 matrices. Pass pixel coordinates. */
    export const GLSL_BAYER = /* glsl */ `
    float bayer2(vec2 a) {
      a = floor(a);
      return fract(a.x * 0.5 + a.y * a.y * 0.75);
    }
    
    float bayer4(vec2 a) {
      return bayer2(0.5 * a) * 0.25 + bayer2(a);
    }
    
    float bayer8(vec2 a) {
      return bayer4(0.5 * a) * 0.25 + bayer2(a);
    }
    `;
    
    /** Hash, simplex and fBm together, in dependency order. */
    export const GLSL_NOISE = GLSL_HASH + GLSL_SIMPLEX + GLSL_FBM;
    lib/webgl/use-shader-canvas.ts
    import { useEffect, useRef, useState } from "react";
    
    /** Numbers, vectors (arrays) and, for sampler2D uniforms, an image, canvas or video element. */
    export type UniformValue = number | boolean | readonly number[] | Float32Array | TexImageSource;
    
    const isTextureSource = (value: UniformValue): value is TexImageSource =>
      typeof value === "object" && value !== null && !Array.isArray(value) && !ArrayBuffer.isView(value);
    
    /**
     * - `idle`: not started yet (always the value on the server and the first client render)
     * - `running`: animating
     * - `static`: a single frame is on screen (reduced motion, paused, offscreen or tab hidden)
     * - `fallback`: WebGL2 is unavailable, the shader failed to compile or the context was lost
     */
    export type ShaderStatus = "idle" | "running" | "static" | "fallback";
    
    export interface UseShaderCanvasOptions {
      /**
       * Fragment shader source. Unless it starts with `#version`, the hook prepends a header with
       * `precision highp float`, the built-in uniforms `u_time` (seconds), `u_resolution` (pixels),
       * `u_mouse` (0–1, y up) and `u_pointer` (0–1, eases to 1 while the pointer is inside),
       * `in vec2 v_uv` and `out vec4 fragColor`. Output premultiplied alpha.
       */
      fragmentShader: string;
      /**
       * Custom uniforms by name. Read every frame, so updating them never restarts the shader. Pass a
       * function to compute them per frame from refs or other values that change without re-rendering.
       */
      uniforms?: Record<string, UniformValue> | (() => Record<string, UniformValue>);
      /** Clock multiplier. @default 1 */
      speed?: number;
      /** Freezes the clock and keeps the current frame on screen. @default false */
      paused?: boolean;
      /** Fraction of the display resolution to render at, clamped to 0.5–1. @default 1 */
      renderScale?: number;
      /** Highest device pixel ratio used. @default 1.5 */
      maxDpr?: number;
      /** Track the pointer over the container into `u_mouse` and `u_pointer`. @default false */
      pointer?: boolean;
      /** Clock value, in seconds, of the single frame drawn under reduced motion. @default 6 */
      staticTime?: number;
      /**
       * Render even on software WebGL (SwiftShader, llvmpipe), where compiling and every frame run on
       * the CPU and block the main thread. By default those renderers get the fallback. @default false
       */
      allowSoftware?: boolean;
    }
    
    const VERTEX_SHADER = /* glsl */ `#version 300 es
    out vec2 v_uv;
    void main() {
      vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
      v_uv = p;
      gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
    }`;
    
    const FRAGMENT_HEADER = /* glsl */ `#version 300 es
    precision highp float;
    uniform float u_time;
    uniform vec2 u_resolution;
    uniform vec2 u_mouse;
    uniform float u_pointer;
    in vec2 v_uv;
    out vec4 fragColor;
    `;
    
    interface UniformSlot {
      location: WebGLUniformLocation;
      type: number;
    }
    
    const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
    
    function compile(gl: WebGL2RenderingContext, type: number, source: string) {
      const shader = gl.createShader(type);
      if (!shader) return null;
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
      if (process.env.NODE_ENV !== "production") console.error(`useShaderCanvas: shader failed to compile\n${gl.getShaderInfoLog(shader)}`);
      gl.deleteShader(shader);
      return null;
    }
    
    function link(gl: WebGL2RenderingContext, fragmentSource: string) {
      const source = fragmentSource.trimStart().startsWith("#version") ? fragmentSource.trimStart() : FRAGMENT_HEADER + fragmentSource;
      const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
      const fragment = compile(gl, gl.FRAGMENT_SHADER, source);
      const program = vertex && fragment ? gl.createProgram() : null;
      if (program && vertex && fragment) {
        gl.attachShader(program, vertex);
        gl.attachShader(program, fragment);
        gl.linkProgram(program);
        gl.detachShader(program, vertex);
        gl.detachShader(program, fragment);
      }
      if (vertex) gl.deleteShader(vertex);
      if (fragment) gl.deleteShader(fragment);
      if (!program) return null;
      if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program;
      if (process.env.NODE_ENV !== "production") console.error(`useShaderCanvas: program failed to link\n${gl.getProgramInfoLog(program)}`);
      gl.deleteProgram(program);
      return null;
    }
    
    function collectUniforms(gl: WebGL2RenderingContext, program: WebGLProgram) {
      const slots = new Map<string, UniformSlot>();
      const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS) as number;
      for (let i = 0; i < count; i++) {
        const info = gl.getActiveUniform(program, i);
        const location = info && gl.getUniformLocation(program, info.name);
        if (info && location) slots.set(info.name.replace(/\[0\]$/, ""), { location, type: info.type });
      }
      return slots;
    }
    
    function setUniform(gl: WebGL2RenderingContext, { location, type }: UniformSlot, value: Exclude<UniformValue, TexImageSource>) {
      const isInt = type === gl.INT || type === gl.BOOL;
      if (typeof value === "boolean" || typeof value === "number") {
        const n = Number(value);
        if (isInt) gl.uniform1i(location, n);
        else gl.uniform1f(location, n);
        return;
      }
      const list = value as number[] | Float32Array;
      if (isInt) gl.uniform1iv(location, Int32Array.from(list));
      else if (type === gl.FLOAT_VEC2) gl.uniform2fv(location, list);
      else if (type === gl.FLOAT_VEC3) gl.uniform3fv(location, list);
      else if (type === gl.FLOAT_VEC4) gl.uniform4fv(location, list);
      else gl.uniform1fv(location, list);
    }
    
    /**
     * Renders a fragment shader with WebGL2 on a canvas the hook creates inside the element you
     * attach `ref` to (give that element a size and `position: relative`, `absolute` or `fixed`).
     *
     * One fullscreen triangle, no three.js. The canvas follows the element's size, caps the pixel
     * ratio, pauses offscreen and in hidden tabs, draws a single static frame under reduced motion
     * (software WebGL such as SwiftShader gets the fallback unless allowSoftware is set),
     * recovers from context loss and releases the GPU context on unmount. Uniform values can be
     * numbers, arrays (vec2–vec4 or float arrays) or, for sampler2D uniforms, an image, canvas or
     * video element (uploaded once it has pixels; pass a new element or re-render to refresh). When `status` is
     * `fallback` (or before the first frame), whatever you render behind the canvas shows through.
     */
    export function useShaderCanvas<T extends HTMLElement = HTMLDivElement>(options: UseShaderCanvasOptions) {
      const ref = useRef<T>(null);
      const [status, setStatus] = useState<ShaderStatus>("idle");
      const latest = useRef(options);
      const wake = useRef<() => void>(() => {});
      const time = useRef(0);
    
      // Keep the render loop reading fresh props, and let it react to paused/uniform changes.
      useEffect(() => {
        latest.current = options;
        wake.current();
      });
    
      const { fragmentShader } = options;
    
      useEffect(() => {
        const host = ref.current;
        if (!host) return;
    
        const canvas = document.createElement("canvas");
        canvas.setAttribute("aria-hidden", "true");
        Object.assign(canvas.style, {
          position: "absolute",
          inset: "0",
          width: "100%",
          height: "100%",
          display: "block",
          pointerEvents: "none",
          opacity: "0",
          transition: "opacity 500ms ease-out",
        });
        host.appendChild(canvas);
    
        const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
        const mouse = [0.5, 0.5];
        const mouseTarget = [0.5, 0.5];
        let gl: WebGL2RenderingContext | null = null;
        let program: WebGLProgram | null = null;
        let uniforms = new Map<string, UniformSlot>();
        const textures = new Map<string, { texture: WebGLTexture; unit: number; source: TexImageSource | null; ready: boolean }>();
        let reported: ShaderStatus = "idle";
        let raf = 0;
        let lastFrame = 0;
        let clock = 0;
        let pointer = 0;
        let pointerTarget = 0;
        let inView = false;
        let pageVisible = document.visibilityState === "visible";
        let needsResize = true;
        let drawn = false;
        let lost = false;
        let software = false;
    
        const report = (next: ShaderStatus) => {
          if (next === reported) return;
          reported = next;
          setStatus(next);
        };
    
        const build = () => {
          gl ??= canvas.getContext("webgl2", {
            alpha: true,
            antialias: false,
            depth: false,
            stencil: false,
            premultipliedAlpha: true,
            powerPreference: "low-power",
          });
          if (gl) {
            const info = gl.getExtension("WEBGL_debug_renderer_info");
            const renderer = info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER));
            software = /swiftshader|llvmpipe|softpipe|software|basic render/i.test(renderer);
          }
          // On software WebGL even compiling blocks the main thread, so use the CSS fallback instead.
          program = gl && !(software && !latest.current.allowSoftware) ? link(gl, fragmentShader) : null;
          uniforms = gl && program ? collectUniforms(gl, program) : new Map();
          textures.clear();
          needsResize = true;
          return program !== null;
        };
    
        const resize = () => {
          if (!gl) return;
          const { renderScale = 1, maxDpr = 1.5 } = latest.current;
          const scale = Math.min(window.devicePixelRatio || 1, maxDpr) * clamp(renderScale, 0.5, 1);
          const width = Math.max(1, Math.round(host.clientWidth * scale));
          const height = Math.max(1, Math.round(host.clientHeight * scale));
          if (canvas.width !== width || canvas.height !== height) {
            canvas.width = width;
            canvas.height = height;
          }
          gl.viewport(0, 0, width, height);
          needsResize = false;
        };
    
        const draw = (time: number) => {
          if (!gl || !program || lost) return;
          if (needsResize) resize();
          gl.useProgram(program);
          const set = (name: string, value: UniformValue) => {
            const slot = uniforms.get(name);
            if (!slot || !gl) return;
            if (isTextureSource(value)) bindTexture(name, slot, value);
            else setUniform(gl, slot, value);
          };
          set("u_time", time);
          set("u_resolution", [canvas.width, canvas.height]);
          set("u_mouse", mouse);
          set("u_pointer", pointer);
          const source = latest.current.uniforms;
          const custom = typeof source === "function" ? source() : source;
          if (custom) for (const name in custom) set(name, custom[name]);
          gl.drawArrays(gl.TRIANGLES, 0, 3);
          if (!drawn) {
            drawn = true;
            canvas.style.opacity = "1";
          }
        };
    
        // Textures upload once per source (and every frame for video), as soon as the source has pixels.
        const bindTexture = (name: string, slot: UniformSlot, source: TexImageSource) => {
          if (!gl || slot.type !== gl.SAMPLER_2D) return;
          let entry = textures.get(name);
          if (!entry) {
            const texture = gl.createTexture();
            if (!texture) return;
            entry = { texture, unit: textures.size, source: null, ready: false };
            textures.set(name, entry);
          }
          gl.activeTexture(gl.TEXTURE0 + entry.unit);
          gl.bindTexture(gl.TEXTURE_2D, entry.texture);
          const hasPixels =
            typeof HTMLImageElement !== "undefined" && source instanceof HTMLImageElement
              ? source.complete && source.naturalWidth > 0
              : typeof HTMLVideoElement !== "undefined" && source instanceof HTMLVideoElement
                ? source.readyState >= 2
                : true;
          const isVideo = typeof HTMLVideoElement !== "undefined" && source instanceof HTMLVideoElement;
          if (hasPixels && (entry.source !== source || !entry.ready || isVideo)) {
            gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
            gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
            gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
            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);
            entry.source = source;
            entry.ready = true;
          }
          gl.uniform1i(slot.location, entry.unit);
        };
    
        const still = () => reducedMotion.matches;
        const shouldRun = () => !lost && program !== null && inView && pageVisible && !latest.current.paused && !still();
    
        const frame = (now: number) => {
          raf = 0;
          if (!shouldRun()) return settle();
          const dt = lastFrame ? Math.min((now - lastFrame) / 1000, 0.1) : 0;
          lastFrame = now;
          clock += dt * (latest.current.speed ?? 1);
          const ease = 1 - Math.exp(-dt * 6);
          mouse[0] += (mouseTarget[0] - mouse[0]) * ease;
          mouse[1] += (mouseTarget[1] - mouse[1]) * ease;
          pointer += (pointerTarget - pointer) * ease;
          draw(clock);
          time.current = clock;
          report("running");
          raf = requestAnimationFrame(frame);
        };
    
        // Not animating: keep one up-to-date frame on screen.
        const settle = () => {
          lastFrame = 0;
          if (lost || !program) return;
          time.current = still() ? (latest.current.staticTime ?? 6) : clock;
          draw(time.current);
          report("static");
        };
    
        const update = () => {
          if (shouldRun()) {
            if (!raf) raf = requestAnimationFrame(frame);
            return;
          }
          if (raf) cancelAnimationFrame(raf);
          raf = 0;
          settle();
        };
    
        const intersection = new IntersectionObserver(
          ([entry]) => {
            inView = entry.isIntersecting;
            update();
          },
          { rootMargin: "120px" },
        );
        const resizeObserver = new ResizeObserver(() => {
          needsResize = true;
          if (!raf) settle();
        });
        const onVisibility = () => {
          pageVisible = document.visibilityState === "visible";
          update();
        };
        const onPointerMove = (event: PointerEvent) => {
          const rect = host.getBoundingClientRect();
          const x = (event.clientX - rect.left) / rect.width;
          const y = 1 - (event.clientY - rect.top) / rect.height;
          const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1;
          if (inside) {
            mouseTarget[0] = x;
            mouseTarget[1] = y;
          }
          pointerTarget = inside ? 1 : 0;
        };
        const onPointerLeave = () => {
          pointerTarget = 0;
        };
        const onContextLost = (event: Event) => {
          event.preventDefault();
          lost = true;
          if (raf) cancelAnimationFrame(raf);
          raf = 0;
          report("fallback");
        };
        const onContextRestored = () => {
          lost = false;
          if (build()) update();
          else report("fallback");
        };
        const trackPointer = latest.current.pointer === true;
    
        // Start on the next frame: keeps setup out of the effect body and off the hydration task.
        const start = requestAnimationFrame(() => {
          if (!build()) return report("fallback");
          canvas.addEventListener("webglcontextlost", onContextLost);
          canvas.addEventListener("webglcontextrestored", onContextRestored);
          document.addEventListener("visibilitychange", onVisibility);
          reducedMotion.addEventListener("change", update);
          if (trackPointer) {
            window.addEventListener("pointermove", onPointerMove, { passive: true });
            document.documentElement.addEventListener("pointerleave", onPointerLeave);
          }
          intersection.observe(host);
          resizeObserver.observe(host);
          wake.current = update;
        });
    
        return () => {
          cancelAnimationFrame(start);
          if (raf) cancelAnimationFrame(raf);
          wake.current = () => {};
          intersection.disconnect();
          resizeObserver.disconnect();
          canvas.removeEventListener("webglcontextlost", onContextLost);
          canvas.removeEventListener("webglcontextrestored", onContextRestored);
          document.removeEventListener("visibilitychange", onVisibility);
          reducedMotion.removeEventListener("change", update);
          window.removeEventListener("pointermove", onPointerMove);
          document.documentElement.removeEventListener("pointerleave", onPointerLeave);
          if (gl) {
            textures.forEach((entry) => gl?.deleteTexture(entry.texture));
            if (program) gl.deleteProgram(program);
            gl.getExtension("WEBGL_lose_context")?.loseContext();
          }
          canvas.remove();
        };
      }, [fragmentShader]);
    
      /** `time` holds the shader clock (seconds, as passed to u_time), e.g. to timestamp clicks. */
      return { ref, status, time };
    }
  3. Copy the source

    components/ui/dithered-waves/dithered-waves.tsx
    "use client";
    
    import type { ComponentProps, ReactNode } from "react";
    import { cn } from "@/lib/utils";
    import { hexToVec3 } from "@/lib/color";
    import { GLSL_BAYER, GLSL_NOISE } from "@/lib/webgl/noise.glsl";
    import { useShaderCanvas } from "@/lib/webgl/use-shader-canvas";
    
    export interface DitheredWavesProps extends Omit<ComponentProps<"div">, "children"> {
      /** Content rendered above the waves. */
      children?: ReactNode;
      /** Size of one dither pixel in CSS pixels, 2–8. @default 3 */
      pixelSize?: number;
      /** Ink color. @default "#09090B" */
      color?: string;
      /** Paper color. @default "#FFFFFF" */
      backgroundColor?: string;
      /** Wave density from 0 to 1.5. @default 1 */
      intensity?: number;
      /** Wave speed multiplier. @default 1 */
      speed?: number;
      /** Freeze on the current frame. @default false */
      paused?: boolean;
    }
    
    const FRAGMENT = /* glsl */ `${GLSL_NOISE}${GLSL_BAYER}
    uniform float u_pixel; uniform vec3 u_ink; uniform vec3 u_paper; uniform float u_intensity;
    void main() {
      vec2 cell = floor(gl_FragCoord.xy / u_pixel);
      vec2 uv = cell * u_pixel / u_resolution;
      vec2 p = vec2(uv.x * u_resolution.x / u_resolution.y, uv.y);
      float t = u_time * 0.5;
      float w = sin(p.y * 9.0 + sin(p.x * 2.6 + t) * 1.6 + fbm(p * 1.5 + t * 0.2, 3) * 2.0 - t * 0.6) * 0.5 + 0.5;
      float v = w * smoothstep(1.05, 0.0, uv.y) * u_intensity;
      float ink = step(bayer8(cell) + 0.001, v);
      fragColor = vec4(mix(u_paper, u_ink, ink), 1.0);
    }`;
    
    export function DitheredWaves({
      children,
      pixelSize = 3,
      color = "#09090B",
      backgroundColor = "#FFFFFF",
      intensity = 1,
      speed = 1,
      paused = false,
      className,
      style,
      ...props
    }: DitheredWavesProps) {
      // One canvas pixel per CSS pixel keeps the dither grid crisp and pixelSize exact.
      const { ref } = useShaderCanvas<HTMLDivElement>({
        fragmentShader: FRAGMENT,
        maxDpr: 1,
        speed,
        paused,
        uniforms: {
          u_pixel: Math.min(8, Math.max(2, Math.round(pixelSize))),
          u_ink: hexToVec3(color),
          u_paper: hexToVec3(backgroundColor),
          u_intensity: intensity,
        },
      });
      return (
        <div
          className={cn("relative isolate overflow-hidden", className)}
          style={{
            background: `radial-gradient(color-mix(in oklab, ${color} 35%, transparent) 1px, transparent 1.5px) 0 0 / ${pixelSize * 2}px ${pixelSize * 2}px, linear-gradient(to top, color-mix(in oklab, ${color} 18%, ${backgroundColor}), ${backgroundColor} 70%)`,
            ...style,
          }}
          {...props}
        >
          <div ref={ref} aria-hidden="true" className="absolute inset-0 -z-10 [&>canvas]:[image-rendering:pixelated]" />
          {children}
        </div>
      );
    }

Usage

import { DitheredWaves } from "@/components/ui/dithered-waves/dithered-waves";

<DitheredWaves className="h-80" pixelSize={3} />

API reference

PropTypeDefaultDescription
childrenReactNode–Content rendered above the waves.
pixelSizenumber3Size of one dither pixel in CSS pixels, 2–8.
colorstring"#09090B"Ink color.
backgroundColorstring"#FFFFFF"Paper color.
intensitynumber1Wave density from 0 to 1.5.
speednumber1Wave speed multiplier.
pausedbooleanfalseFreeze on the current frame.

Also accepts every native <div> attribute, including ref and className.

  • Waves are sampled once per dither cell and compared with an 8×8 Bayer threshold, so tones become ordered dot patterns. The canvas renders one pixel per CSS pixel with pixelated scaling, which keeps the grid crisp.
  • Built on useShaderCanvas: it pauses offscreen and in hidden tabs, caps the pixel ratio at 1.5, recovers from context loss and releases the GPU on unmount.

Accessibility

  • The canvas is decorative (aria-hidden); children render above it as normal content.
  • With reduced motion the shader draws a single still frame. Without WebGL2, or on software rendering with no GPU, a CSS fallback in the same colors is shown.