Skip to content

Palette Dither Field

Layered waves Bayer-dithered to any palette, with live palette crossfades.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/palette-dither-field.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/glyph/palettes.ts
    import { hexToRgb, type Vec3 } from "@/lib/color";
    
    /**
     * Named palettes for glyph and dither pieces. Each is 2–8 hex colors ordered from dark to light,
     * so a value of 0 maps to the first color and 1 to the last.
     */
    export const GLYPH_PALETTES = {
      midnight: ["#070b1f", "#141d45", "#24347a", "#3d57b8", "#7896ec", "#dfe7ff"],
      peach: ["#3b1611", "#83362a", "#cf6448", "#f19774", "#ffc9a9", "#fff3ea"],
      ember: ["#12070a", "#45100e", "#9a240f", "#e2531a", "#ffa03a", "#ffe28c"],
      moss: ["#0d1a12", "#1e3a26", "#3a6a3e", "#6c994d", "#b6ca7c", "#f0f3d8"],
      lilac: ["#1b1430", "#3d2b6b", "#6b54ae", "#a38de0", "#d6caf6", "#f7f3ff"],
      mono: ["#09090b", "#3f3f46", "#a1a1aa", "#fafafa"],
      /** Built around the library accent (`--primary`, #2563eb). */
      accent: ["#081536", "#11307f", "#2563eb", "#7aa3f8", "#dce8ff"],
    } as const satisfies Record<string, readonly string[]>;
    
    export type GlyphPaletteName = keyof typeof GLYPH_PALETTES;
    
    /** A palette preset name, or 2–8 hex colors ordered dark to light. */
    export type GlyphPaletteInput = GlyphPaletteName | readonly string[];
    
    /** Display names for the presets, in the order demos list them. */
    export const GLYPH_PALETTE_LABELS: Record<GlyphPaletteName, string> = {
      midnight: "Midnight",
      peach: "Peach",
      ember: "Ember",
      moss: "Moss",
      lilac: "Lilac",
      mono: "Mono",
      accent: "Accent",
    };
    
    export const MIN_PALETTE_COLORS = 2;
    export const MAX_PALETTE_COLORS = 8;
    
    const HEX = /^#(?:[\da-f]{3}|[\da-f]{6})$/i;
    
    export const isGlyphPaletteName = (value: unknown): value is GlyphPaletteName => typeof value === "string" && value in GLYPH_PALETTES;
    
    /**
     * Resolves a preset name or a color list to 2–8 hex colors. Invalid entries are dropped and the
     * list is cut to 8; fewer than 2 valid colors fall back to `mono`.
     */
    export function resolvePalette(input: GlyphPaletteInput | undefined, fallback: GlyphPaletteName = "mono"): string[] {
      if (input === undefined) return [...GLYPH_PALETTES[fallback]];
      if (typeof input === "string") return [...(isGlyphPaletteName(input) ? GLYPH_PALETTES[input] : GLYPH_PALETTES[fallback])];
      const colors = input.map((c) => c.trim()).filter((c) => HEX.test(c)).slice(0, MAX_PALETTE_COLORS);
      return colors.length >= MIN_PALETTE_COLORS ? colors : [...GLYPH_PALETTES[fallback]];
    }
    
    /** A palette's colors as 0–255 RGB triples. */
    export function paletteRgb(palette: readonly string[]): Vec3[] {
      return palette.map((c) => hexToRgb(c));
    }
    
    /** Linear blend of two RGB triples. */
    export function mixRgb(a: Vec3, b: Vec3, t: number): Vec3 {
      return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
    }
    
    /** The color at `t` (0 = first, 1 = last) along a palette, blending between neighbors. */
    export function samplePalette(rgb: readonly Vec3[], t: number): Vec3 {
      if (rgb.length === 0) return [0, 0, 0];
      if (rgb.length === 1) return rgb[0];
      const x = Math.min(1, Math.max(0, t)) * (rgb.length - 1);
      const i = Math.min(rgb.length - 2, Math.floor(x));
      return mixRgb(rgb[i], rgb[i + 1], x - i);
    }
    
    /** An RGB triple as a CSS color. */
    export const rgbCss = ([r, g, b]: Vec3, alpha = 1) =>
      alpha >= 1 ? `rgb(${Math.round(r)} ${Math.round(g)} ${Math.round(b)})` : `rgb(${Math.round(r)} ${Math.round(g)} ${Math.round(b)} / ${alpha})`;
    
    /** A CSS gradient through every palette color, for static fallbacks. */
    export function paletteGradient(palette: readonly string[], direction = "to top") {
      return `linear-gradient(${direction}, ${palette.join(", ")})`;
    }
    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/palette-dither-field/palette-dither-field.tsx
    "use client";
    
    import { useEffect, useImperativeHandle, useRef, useState, type ComponentProps, type ReactNode, type Ref } from "react";
    import { cn } from "@/lib/utils";
    import { hexToVec3 } from "@/lib/color";
    import { paletteGradient, resolvePalette, type GlyphPaletteInput } from "@/lib/glyph/palettes";
    import { GLSL_BAYER, GLSL_NOISE } from "@/lib/webgl/noise.glsl";
    import { useShaderCanvas } from "@/lib/webgl/use-shader-canvas";
    
    export interface PaletteDitherFieldHandle {
      /** Crossfades to another palette (preset name or 2–8 hex colors) in about 400ms, without restarting the waves. */
      setPalette: (next: GlyphPaletteInput) => void;
    }
    
    export interface PaletteDitherFieldProps extends Omit<ComponentProps<"div">, "children" | "ref"> {
      /** Content rendered above the field. */
      children?: ReactNode;
      /** A preset (midnight, peach, ember, moss, lilac, mono, accent) or 2–8 hex colors from dark to light. Changing it crossfades. @default "midnight" */
      palette?: GlyphPaletteInput;
      /** Size of one dither pixel in CSS pixels, 2–12. @default 4 */
      pixelSize?: number;
      /** Wave speed multiplier. @default 1 */
      speed?: number;
      /** Wave frequency multiplier: below 1 for broad swells, above 1 for tight ripples. @default 1 */
      scale?: number;
      /** Number of layered waves, 3–5. @default 4 */
      layers?: number;
      /** Direction the waves travel, in degrees (0 = right, 90 = up). @default 20 */
      direction?: number;
      /** The pointer adds a local ripple to the field. @default true */
      interactive?: boolean;
      /** Freeze on the current frame. @default false */
      paused?: boolean;
      /** Imperative handle with `setPalette()`. */
      ref?: Ref<PaletteDitherFieldHandle>;
    }
    
    const MAX = 8;
    
    const FRAGMENT = /* glsl */ `${GLSL_NOISE}${GLSL_BAYER}
    uniform float u_pixel;
    uniform float u_scale;
    uniform float u_layers;
    uniform vec2 u_dir;
    uniform float u_interactive;
    uniform vec3 u_next[${MAX}];
    uniform float u_nextCount;
    uniform vec3 u_prev[${MAX}];
    uniform float u_prevCount;
    uniform float u_fadeStart;
    uniform float u_fadeLength;
    
    float field(vec2 p, float t) {
      float v = 0.0;
      float total = 0.0;
      for (int i = 0; i < 5; i++) {
        float fi = float(i);
        if (fi >= u_layers) break;
        // Each layer leans a little off the main direction, with its own frequency and pace.
        float a = (fi - 1.5) * 0.32;
        vec2 d = vec2(u_dir.x * cos(a) - u_dir.y * sin(a), u_dir.x * sin(a) + u_dir.y * cos(a));
        float f = (1.7 + fi * 1.2) * u_scale;
        float w = 1.0 / (1.0 + fi * 0.6);
        v += w * sin(dot(p, d) * f - t * (0.8 + fi * 0.27) + fi * 1.9 + snoise(p * 0.6 + fi * 3.1 + t * 0.05) * 0.55);
        total += w;
      }
      v /= total;
      v += fbm(p * 1.2 + t * 0.06, 3) * 0.22;
      return clamp(v * 0.5 + 0.5, 0.0, 1.0);
    }
    
    void main() {
      vec2 cell = floor(gl_FragCoord.xy / u_pixel);
      vec2 uv = (cell + 0.5) * u_pixel / u_resolution;
      float aspect = u_resolution.x / u_resolution.y;
      vec2 p = vec2(uv.x * aspect, uv.y);
      float v = field(p * 2.0, u_time);
      vec2 m = vec2(u_mouse.x * aspect, u_mouse.y);
      float d = distance(p, m);
      v += u_pointer * u_interactive * exp(-d * 6.0) * sin(d * 42.0 - u_time * 6.0) * 0.3;
      v = clamp(v, 0.0, 1.0);
    
      // Crossfade by dithering too: more pixels take the new palette as the fade advances.
      float progress = clamp((u_time - u_fadeStart) / max(u_fadeLength, 0.001), 0.0, 1.0);
      bool useNext = bayer8(cell.yx + vec2(5.0, 3.0)) < progress;
      float count = useNext ? u_nextCount : u_prevCount;
    
      // Quantize to the palette's steps, then dither between the two nearest.
      float x = v * (count - 1.0);
      float idx = min(floor(x) + step(bayer8(cell), fract(x)), count - 1.0);
      vec3 color = vec3(0.0);
      for (int k = 0; k < ${MAX}; k++) {
        if (float(k) == idx) color = useNext ? u_next[k] : u_prev[k];
      }
      fragColor = vec4(color, 1.0);
    }`;
    
    function pack(colors: readonly string[]) {
      const out = new Float32Array(MAX * 3);
      colors.slice(0, MAX).forEach((c, i) => out.set(hexToVec3(c), i * 3));
      return out;
    }
    
    /**
     * A full-bleed field of layered sine and noise waves, ordered-dithered (8×8 Bayer) to a palette of
     * 2–8 colors: the height is quantized to the palette's steps and dithered between the two nearest.
     * The pointer adds a local ripple. `ref.setPalette()` (or a new `palette` prop) crossfades palettes
     * with a dither of its own, without restarting the waves. Falls back to a CSS gradient of the
     * palette without WebGL; pauses offscreen and holds a still frame under reduced motion.
     */
    export function PaletteDitherField({
      children,
      palette = "midnight",
      pixelSize = 4,
      speed = 1,
      scale = 1,
      layers = 4,
      direction = 20,
      interactive = true,
      paused = false,
      ref,
      className,
      style,
      ...props
    }: PaletteDitherFieldProps) {
      const colors = resolvePalette(palette, "midnight");
      const key = colors.join(",");
      // The palette set imperatively shows in the CSS fallback until the prop changes again.
      const [override, setOverride] = useState<{ key: string; colors: string[] } | null>(null);
      const [seenKey, setSeenKey] = useState(key);
      if (seenKey !== key) {
        setSeenKey(key);
        setOverride(null);
      }
      const shown = override?.colors ?? colors;
      const fade = useRef({ prev: pack(colors), prevCount: colors.length, next: pack(colors), nextCount: colors.length, start: -1e6 });
      const status = useRef("idle");
    
      const angle = (direction * Math.PI) / 180;
      const dir = [Math.cos(angle), Math.sin(angle)];
      const { ref: hostRef, status: shaderStatus, time } = useShaderCanvas<HTMLDivElement>({
        fragmentShader: FRAGMENT,
        // One canvas pixel per CSS pixel keeps the dither grid crisp and pixelSize exact.
        maxDpr: 1,
        speed,
        paused,
        pointer: interactive,
        uniforms: () => {
          const f = fade.current;
          return {
            u_pixel: Math.min(12, Math.max(2, Math.round(pixelSize))),
            u_scale: Math.max(0.2, scale),
            u_layers: Math.min(5, Math.max(3, Math.round(layers))),
            u_dir: dir,
            u_interactive: interactive ? 1 : 0,
            u_next: f.next,
            u_nextCount: f.nextCount,
            u_prev: f.prev,
            u_prevCount: f.prevCount,
            u_fadeStart: f.start,
            // The shader clock runs at `speed`, so scale the fade to stay about 400ms.
            u_fadeLength: 0.4 * Math.max(0.05, speed),
          };
        },
      });
    
      useEffect(() => {
        status.current = shaderStatus;
      }, [shaderStatus]);
    
      /** Starts a crossfade from whatever is on screen now. Instant when the field isn't animating. */
      const fadeTo = (next: string[]) => {
        const f = fade.current;
        const now = time.current;
        const progress = Math.min(1, Math.max(0, (now - f.start) / (0.4 * Math.max(0.05, speed))));
        if (progress >= 0.5) {
          f.prev = f.next;
          f.prevCount = f.nextCount;
        }
        f.next = pack(next);
        f.nextCount = next.length;
        f.start = status.current === "running" ? now : -1e6;
      };
    
      // A new `palette` prop crossfades like setPalette.
      const lastKey = useRef(key);
      useEffect(() => {
        if (lastKey.current === key) return;
        lastKey.current = key;
        fadeTo(key.split(","));
      });
    
      useImperativeHandle(ref, () => ({
        setPalette: (input) => {
          const next = resolvePalette(input, "midnight");
          fadeTo(next);
          // Also refreshes the CSS fallback and wakes a paused or still field to redraw.
          setOverride({ key, colors: next });
        },
      }));
    
      return (
        <div
          className={cn("relative isolate min-h-96 overflow-hidden", className)}
          style={{ background: paletteGradient(shown, "to top"), ...style }}
          {...props}
        >
          <div ref={hostRef} aria-hidden="true" className="absolute inset-0 -z-10 [&>canvas]:[image-rendering:pixelated]" />
          {children}
        </div>
      );
    }

Usage

import { PaletteDitherField, type PaletteDitherFieldHandle } from "@/components/ui/palette-dither-field/palette-dither-field";

const field = useRef<PaletteDitherFieldHandle>(null);

<PaletteDitherField ref={field} palette="ember" pixelSize={4} className="h-[480px]">
  <Hero />
</PaletteDitherField>

// Later: crossfade without restarting the waves.
field.current?.setPalette(["#0b1026", "#2d3f8c", "#9db6ff"]);

API reference

PropTypeDefaultDescription
childrenReactNode–Content rendered above the field.
paletteGlyphPaletteInput"midnight"A preset (midnight, peach, ember, moss, lilac, mono, accent) or 2–8 hex colors from dark to light. Changing it crossfades.
pixelSizenumber4Size of one dither pixel in CSS pixels, 2–12.
speednumber1Wave speed multiplier.
scalenumber1Wave frequency multiplier: below 1 for broad swells, above 1 for tight ripples.
layersnumber4Number of layered waves, 3–5.
directionnumber20Direction the waves travel, in degrees (0 = right, 90 = up).
interactivebooleantrueThe pointer adds a local ripple to the field.
pausedbooleanfalseFreeze on the current frame.
refRef<PaletteDitherFieldHandle>–Imperative handle with `setPalette()`.

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

  • A WebGL2 fragment shader sums 3–5 sine layers (each leaning a little off `direction`, with its own frequency, pace and a simplex wobble) plus fBm into a height field. The height is quantized to the palette's steps and ordered-dithered (8×8 Bayer) between the two nearest colors, one cell per `pixelSize` CSS pixels.
  • Palettes are presets from `src/lib/glyph/palettes.ts` (midnight, peach, ember, moss, lilac, mono, accent) or 2–8 hex colors from dark to light. A crossfade is a dither too: more pixels take the new palette over about 400ms.
  • Without WebGL2 (or on software renderers) the shader is skipped and a CSS gradient through the palette shows instead.

Accessibility

  • The field is a decorative canvas (aria-hidden) behind your content, which keeps its own semantics.
  • Under reduced motion it draws one still frame, and palette changes switch instantly instead of crossfading. It pauses offscreen and in hidden tabs.
  • The pointer ripple is an extra; nothing depends on it. Check the contrast of text you place on top against the lightest and darkest palette colors.