Skip to content

Ink Bloom Footer

A footer where dragging drops ink that blooms behind a giant wordmark.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/ink-bloom-footer.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion 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/canvas-loop.ts
    /**
     * The render loop every glyph and dither canvas shares: sizes the canvas to its host at a capped
     * pixel ratio, runs only while the host is on screen, the tab is visible, nothing is paused and
     * the user hasn't asked for reduced motion, and otherwise keeps one up-to-date still frame.
     * Framework-free: start it in an effect and dispose it in the cleanup. Browser only.
     */
    
    export interface CanvasLoopFrame {
      /** Seconds since the previous frame (capped at 0.05), or 0 for a redraw without advancing. */
      dt: number;
      /** Seconds the loop has been running. */
      time: number;
      /** True under reduced motion: draw a calm, finished-looking frame. */
      still: boolean;
      /** Host size in CSS pixels. */
      width: number;
      height: number;
      /** Device pixel ratio of the canvas (capped). */
      dpr: number;
    }
    
    export interface CanvasLoopOptions {
      /** Element observed for size and visibility. */
      host: HTMLElement;
      /** Canvas sized to the host times the pixel ratio. */
      canvas: HTMLCanvasElement;
      /** Advance by `frame.dt` and draw. Called every animation frame while running, and with dt 0 after resizes and redraws. */
      render: (frame: CanvasLoopFrame) => void;
      /** Called after the canvas is resized, before it is redrawn. */
      resize?: (frame: CanvasLoopFrame) => void;
      /** Read before every frame; while it returns true the loop stops and the last frame stays. */
      isPaused?: () => boolean;
      /** Highest device pixel ratio used. @default 2 */
      maxDpr?: number;
      /** Margin around the viewport that still counts as on screen. @default "120px" */
      rootMargin?: string;
    }
    
    export interface CanvasLoop {
      /** Re-checks paused, visibility and reduced motion, and starts or stops the loop. Call it when props change. */
      wake(): void;
      /** Draws one frame without advancing (no-op while the loop is running, since the next frame will). */
      redraw(): void;
      /** Stops the loop and removes every observer and listener. */
      dispose(): void;
    }
    
    export function runCanvasLoop(options: CanvasLoopOptions): CanvasLoop {
      const { host, canvas, rootMargin = "120px" } = options;
      const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
      let width = 0;
      let height = 0;
      let dpr = 1;
      let raf = 0;
      let last = 0;
      let time = 0;
      let inView = false;
      let disposed = false;
    
      const frame = (dt: number): CanvasLoopFrame => ({ dt, time, still: reducedMotion.matches, width, height, dpr });
      const shouldRun = () => !disposed && inView && !document.hidden && !reducedMotion.matches && !options.isPaused?.();
    
      const tick = (now: number) => {
        raf = 0;
        if (!shouldRun()) return;
        const dt = last ? Math.min(0.05, (now - last) / 1000) : 0;
        last = now;
        time += dt;
        options.render(frame(dt));
        raf = requestAnimationFrame(tick);
      };
    
      const wake = () => {
        if (disposed) return;
        if (shouldRun()) {
          if (!raf) {
            last = 0;
            raf = requestAnimationFrame(tick);
          }
          return;
        }
        if (raf) cancelAnimationFrame(raf);
        raf = 0;
      };
    
      const redraw = () => {
        if (!disposed && !raf && width > 0) options.render(frame(0));
      };
    
      const resize = () => {
        width = Math.max(1, host.clientWidth);
        height = Math.max(1, host.clientHeight);
        dpr = Math.min(window.devicePixelRatio || 1, options.maxDpr ?? 2);
        const w = Math.max(1, Math.round(width * dpr));
        const h = Math.max(1, Math.round(height * dpr));
        if (canvas.width !== w || canvas.height !== h) {
          canvas.width = w;
          canvas.height = h;
        }
        options.resize?.(frame(0));
        // Resizing clears the canvas, so repaint right away even while running.
        options.render(frame(0));
      };
    
      const resizeObserver = new ResizeObserver(resize);
      const intersection = new IntersectionObserver(
        ([entry]) => {
          inView = entry?.isIntersecting ?? true;
          wake();
        },
        { rootMargin },
      );
      const onVisibility = () => wake();
      const onMotionChange = () => {
        wake();
        redraw();
      };
    
      resize();
      resizeObserver.observe(host);
      intersection.observe(host);
      document.addEventListener("visibilitychange", onVisibility);
      reducedMotion.addEventListener("change", onMotionChange);
    
      return {
        wake,
        redraw,
        dispose() {
          disposed = true;
          if (raf) cancelAnimationFrame(raf);
          raf = 0;
          resizeObserver.disconnect();
          intersection.disconnect();
          document.removeEventListener("visibilitychange", onVisibility);
          reducedMotion.removeEventListener("change", onMotionChange);
        },
      };
    }
    lib/random.ts
    /**
     * Seeded randomness for visuals: dot layouts, characters' moods, print glitches. The same seed
     * gives the same sequence on the server and in the first client frame, so nothing shifts or
     * mismatches on hydration. Not for anything security-related.
     */
    
    /** A 32-bit hash of a string or number, to turn a `seed` prop into a generator state. */
    export function hashSeed(seed: string | number): number {
      const text = typeof seed === "number" ? `n${seed}` : seed;
      // FNV-1a, then a final avalanche so short, similar seeds still diverge.
      let h = 0x811c9dc5;
      for (let i = 0; i < text.length; i++) {
        h ^= text.charCodeAt(i);
        h = Math.imul(h, 0x01000193);
      }
      h ^= h >>> 16;
      h = Math.imul(h, 0x7feb352d);
      h ^= h >>> 15;
      return h >>> 0;
    }
    
    /** mulberry32: a small, fast generator of floats in [0, 1). */
    export function mulberry32(state: number): () => number {
      let a = state >>> 0;
      return () => {
        a = (a + 0x6d2b79f5) >>> 0;
        let t = a;
        t = Math.imul(t ^ (t >>> 15), t | 1);
        t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
      };
    }
    
    export interface Random {
      /** A float in [0, 1). */
      next(): number;
      /** A float in [min, max). */
      range(min: number, max: number): number;
      /** An integer from min to max, both included. */
      int(min: number, max: number): number;
      /** True with probability `p`. */
      chance(p: number): boolean;
      /** One item of a non-empty list. */
      pick<T>(list: readonly T[]): T;
      /** An independent generator derived from this seed and a salt, e.g. one per word or per coin. */
      fork(salt: string | number): Random;
    }
    
    /** A seeded generator with a few helpers on top of `mulberry32`. */
    export function createRandom(seed: string | number): Random {
      const base = hashSeed(seed);
      const next = mulberry32(base);
      return {
        next,
        range: (min, max) => min + next() * (max - min),
        int: (min, max) => min + Math.floor(next() * (max - min + 1)),
        chance: (p) => next() < p,
        pick: (list) => list[Math.floor(next() * list.length)],
        fork: (salt) => createRandom(`${base}:${salt}`),
      };
    }
    lib/webgl/ping-pong.ts
    /**
     * Small WebGL2 helpers for multipass effects that feed a frame into the next one (simulations,
     * feedback trails): float render targets, read/write ping-pong pairs (optionally with several
     * textures per target, written in one pass with multiple render targets) and full-screen passes.
     * Framework-free and browser only. `useShaderCanvas` stays the single-pass hook; this is for the
     * cases it doesn't cover.
     */
    
    export interface TargetFormat {
      internalFormat: number;
      format: number;
      type: number;
    }
    
    export interface RenderTarget {
      framebuffer: WebGLFramebuffer;
      /** One texture per color attachment, in attachment order. */
      textures: WebGLTexture[];
      width: number;
      height: number;
    }
    
    export interface PingPong {
      /** The target to sample from this step. */
      readonly read: RenderTarget;
      /** The target to render into this step. */
      readonly write: RenderTarget;
      /** Swaps read and write after a pass. */
      swap(): void;
      /** Reallocates both targets (contents are cleared). */
      resize(width: number, height: number): void;
      /** Clears both targets to zero. */
      clear(): void;
      dispose(): void;
    }
    
    export interface Pass {
      program: WebGLProgram;
      /** A uniform's location (cached), or null if the shader doesn't use it. */
      uniform(name: string): WebGLUniformLocation | null;
      /** Binds the program and draws a full-screen triangle into `target` (or the canvas when null). */
      draw(target: RenderTarget | null, setUniforms?: (pass: Pass) => void): void;
      dispose(): void;
    }
    
    const VERTEX = /* 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 HEADER = `#version 300 es
    precision highp float;
    precision highp sampler2D;
    in vec2 v_uv;
    `;
    
    /** True on software WebGL (SwiftShader, llvmpipe), where multipass effects block the main thread. */
    export function isSoftwareRenderer(gl: WebGL2RenderingContext): boolean {
      const info = gl.getExtension("WEBGL_debug_renderer_info");
      const renderer = info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER));
      return /swiftshader|llvmpipe|softpipe|software|basic render/i.test(renderer);
    }
    
    /**
     * A renderable half-float format (RGBA16F), or null when the device can't render to float
     * textures. Checked by actually completing a framebuffer, since extension support alone lies on
     * some drivers.
     */
    export function halfFloatFormat(gl: WebGL2RenderingContext): TargetFormat | null {
      if (!gl.getExtension("EXT_color_buffer_float") && !gl.getExtension("EXT_color_buffer_half_float")) return null;
      const format: TargetFormat = { internalFormat: gl.RGBA16F, format: gl.RGBA, type: gl.HALF_FLOAT };
      const texture = gl.createTexture();
      const framebuffer = gl.createFramebuffer();
      if (!texture || !framebuffer) return null;
      gl.bindTexture(gl.TEXTURE_2D, texture);
      gl.texImage2D(gl.TEXTURE_2D, 0, format.internalFormat, 4, 4, 0, format.format, format.type, null);
      gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
      gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
      const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
      gl.deleteFramebuffer(framebuffer);
      gl.deleteTexture(texture);
      return ok ? format : null;
    }
    
    /** A framebuffer with `attachments` textures of the given size and format. Linear filtering, clamped edges. */
    export function createTarget(gl: WebGL2RenderingContext, width: number, height: number, format: TargetFormat, attachments = 1): RenderTarget {
      const framebuffer = gl.createFramebuffer()!;
      gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
      const textures: WebGLTexture[] = [];
      for (let i = 0; i < attachments; i++) {
        const texture = gl.createTexture()!;
        gl.bindTexture(gl.TEXTURE_2D, texture);
        gl.texImage2D(gl.TEXTURE_2D, 0, format.internalFormat, width, height, 0, format.format, format.type, null);
        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);
        gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0 + i, gl.TEXTURE_2D, texture, 0);
        textures.push(texture);
      }
      gl.drawBuffers(textures.map((_, i) => gl.COLOR_ATTACHMENT0 + i));
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.bindFramebuffer(gl.FRAMEBUFFER, null);
      return { framebuffer, textures, width, height };
    }
    
    function deleteTarget(gl: WebGL2RenderingContext, target: RenderTarget) {
      target.textures.forEach((t) => gl.deleteTexture(t));
      gl.deleteFramebuffer(target.framebuffer);
    }
    
    /** A read/write pair of targets for passes that sample the previous step. */
    export function createPingPong(gl: WebGL2RenderingContext, width: number, height: number, format: TargetFormat, attachments = 1): PingPong {
      let a = createTarget(gl, width, height, format, attachments);
      let b = createTarget(gl, width, height, format, attachments);
      return {
        get read() {
          return a;
        },
        get write() {
          return b;
        },
        swap() {
          [a, b] = [b, a];
        },
        resize(w, h) {
          deleteTarget(gl, a);
          deleteTarget(gl, b);
          a = createTarget(gl, w, h, format, attachments);
          b = createTarget(gl, w, h, format, attachments);
        },
        clear() {
          for (const t of [a, b]) {
            gl.bindFramebuffer(gl.FRAMEBUFFER, t.framebuffer);
            gl.clearColor(0, 0, 0, 0);
            gl.clear(gl.COLOR_BUFFER_BIT);
          }
          gl.bindFramebuffer(gl.FRAMEBUFFER, null);
        },
        dispose() {
          deleteTarget(gl, a);
          deleteTarget(gl, b);
        },
      };
    }
    
    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;
      console.warn(gl.getShaderInfoLog(shader));
      gl.deleteShader(shader);
      return null;
    }
    
    let emptyVao: WeakMap<WebGL2RenderingContext, WebGLVertexArrayObject> | null = null;
    
    /**
     * A full-screen pass from a fragment shader. Unless the source starts with `#version`, a header
     * with `precision highp float` and `in vec2 v_uv` (0–1) is prepended; declare your own `out`
     * variables (one per attachment of the target you draw into). Returns null if it doesn't compile.
     */
    export function createPass(gl: WebGL2RenderingContext, fragment: string): Pass | null {
      const vs = compile(gl, gl.VERTEX_SHADER, VERTEX);
      const fs = compile(gl, gl.FRAGMENT_SHADER, fragment.trimStart().startsWith("#version") ? fragment : HEADER + fragment);
      if (!vs || !fs) return null;
      const program = gl.createProgram();
      if (!program) return null;
      gl.attachShader(program, vs);
      gl.attachShader(program, fs);
      gl.linkProgram(program);
      gl.deleteShader(vs);
      gl.deleteShader(fs);
      if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
        console.warn(gl.getProgramInfoLog(program));
        gl.deleteProgram(program);
        return null;
      }
      emptyVao ??= new WeakMap();
      if (!emptyVao.has(gl)) emptyVao.set(gl, gl.createVertexArray()!);
      const locations = new Map<string, WebGLUniformLocation | null>();
      const pass: Pass = {
        program,
        uniform(name) {
          if (!locations.has(name)) locations.set(name, gl.getUniformLocation(program, name));
          return locations.get(name)!;
        },
        draw(target, setUniforms) {
          gl.useProgram(program);
          gl.bindVertexArray(emptyVao!.get(gl)!);
          if (target) {
            gl.bindFramebuffer(gl.FRAMEBUFFER, target.framebuffer);
            gl.viewport(0, 0, target.width, target.height);
          } else {
            gl.bindFramebuffer(gl.FRAMEBUFFER, null);
            gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
          }
          setUniforms?.(pass);
          gl.drawArrays(gl.TRIANGLES, 0, 3);
        },
        dispose() {
          gl.deleteProgram(program);
        },
      };
      return pass;
    }
    
    /** Binds a texture to a texture unit and points a sampler uniform at it. */
    export function bindTexture(gl: WebGL2RenderingContext, pass: Pass, name: string, texture: WebGLTexture, unit: number) {
      gl.activeTexture(gl.TEXTURE0 + unit);
      gl.bindTexture(gl.TEXTURE_2D, texture);
      gl.uniform1i(pass.uniform(name), unit);
    }
  3. Copy the source

    components/ui/ink-bloom-footer/ink-bloom-footer.tsx
    "use client";
    
    import { useEffect, useId, useRef, useState, type ComponentProps, type CSSProperties, type ReactNode } from "react";
    import { animate, AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { Eraser } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { hexToRgb } from "@/lib/color";
    import { runCanvasLoop } from "@/lib/glyph/canvas-loop";
    import { createRandom } from "@/lib/random";
    import { bindTexture, createPass, createPingPong, halfFloatFormat, isSoftwareRenderer, type Pass, type PingPong } from "@/lib/webgl/ping-pong";
    
    export interface InkBloomLink {
      /** Link text. */
      label: string;
      /** Destination. */
      href: string;
    }
    
    export interface InkBloomColumn {
      /** Column heading. */
      title: string;
      /** Links under it. */
      links: InkBloomLink[];
    }
    
    export interface InkBloomFooterProps extends Omit<ComponentProps<"footer">, "children"> {
      /** The giant wordmark, bottom left. @default "Fernwick" */
      wordmark?: ReactNode;
      /** Centered testimonial at the top. Pass null to leave it out. */
      quote?: { text: ReactNode; cite: ReactNode } | null;
      /** Link columns, middle right. @default Product, Company and Help */
      columns?: InkBloomColumn[];
      /** Ink colors (hex); each new stroke takes the next. @default yellow, magenta and indigo */
      inks?: string[];
      /** A watercolor wash already resting along the bottom. @default true */
      resting?: boolean;
      /** Paper color behind everything (hex). @default "#f6f1e6" */
      paper?: string;
      /** Small line bottom right, e.g. the copyright. */
      note?: ReactNode;
      /** Called with the color of each new stroke. */
      onInk?: (color: string) => void;
    }
    
    const INKS = ["#f2b705", "#e0457b", "#3b3a98"];
    const DEFAULT_COLUMNS: InkBloomColumn[] = [
      { title: "Product", links: ["Notebooks", "Templates", "Pricing", "Changelog"].map((label) => ({ label, href: "#" })) },
      { title: "Company", links: ["About", "Journal", "Careers", "Press kit"].map((label) => ({ label, href: "#" })) },
      { title: "Help", links: ["Support", "Guides", "Status", "Contact"].map((label) => ({ label, href: "#" })) },
    ];
    const MAX_STAMPS = 24;
    
    /* ----------------------------------------------------------------------------------- shaders */
    
    const NOISE = /* glsl */ `
    float hash(vec2 p) { p = fract(p * vec2(234.34, 435.345)); p += dot(p, p + 34.23); return fract(p.x * p.y); }
    float vnoise(vec2 p) {
      vec2 i = floor(p), f = fract(p);
      vec2 u = f * f * (3.0 - 2.0 * f);
      return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
    }
    float fbm(vec2 p) { return 0.55 * vnoise(p) + 0.28 * vnoise(p * 2.07) + 0.17 * vnoise(p * 4.31); }
    `;
    
    /** Adds water and pigment under the queued brush stamps. Deposits pass through. */
    const SPLAT = /* glsl */ `
    uniform sampler2D u_s0;
    uniform sampler2D u_s1;
    uniform vec4 u_stamps[${MAX_STAMPS}];
    uniform int u_count;
    uniform vec3 u_ink;
    uniform float u_aspect;
    layout(location = 0) out vec4 o0;
    layout(location = 1) out vec4 o1;
    void main() {
      vec4 c = texture(u_s0, v_uv);
      for (int i = 0; i < ${MAX_STAMPS}; i++) {
        if (i >= u_count) break;
        vec4 st = u_stamps[i];
        vec2 d = v_uv - st.xy;
        d.x *= u_aspect;
        float f = smoothstep(1.0, 0.4, length(d) / st.z);
        c.r += f * st.w;
        c.gba += f * st.w * u_ink;
      }
      c.r = min(c.r, 1.4);
      o0 = c;
      o1 = texture(u_s1, v_uv);
    }`;
    
    /**
     * One simulation step. Water flows to drier neighbours through paper whose permeability comes from
     * noise (dry paper resists until a neighbour is quite wet: the branching, cauliflower edges), carrying
     * pigment with it. Water evaporates fastest at the thin wet front, so flow and pigment head for the
     * edge and settle there (dark rims). Fresh water pushing into drying pigment lifts some of it again
     * (pale back-runs).
     */
    const UPDATE = /* glsl */ `
    uniform sampler2D u_s0;
    uniform sampler2D u_s1;
    uniform vec2 u_texel;
    uniform vec2 u_size;
    uniform float u_dt;
    layout(location = 0) out vec4 o0;
    layout(location = 1) out vec4 o1;
    ${NOISE}
    float perm(vec2 uv) {
      float n = fbm(uv * u_size / 7.0);
      return mix(0.2, 1.0, smoothstep(0.28, 0.72, n));
    }
    void main() {
      vec4 c = texture(u_s0, v_uv);
      vec4 d = texture(u_s1, v_uv);
      float pc = perm(v_uv);
      vec2 offs[4] = vec2[](vec2(1.0, 0.0), vec2(-1.0, 0.0), vec2(0.0, 1.0), vec2(0.0, -1.0));
      float dw = 0.0;
      vec3 dp = vec3(0.0);
      vec3 rhoC = c.gba / max(c.r, 0.03);
      for (int i = 0; i < 4; i++) {
        vec2 uv2 = v_uv + offs[i] * u_texel;
        vec4 q = texture(u_s0, uv2);
        float pq = perm(uv2);
        float f = (q.r - c.r) * 0.24 * min(pc, pq);
        // Dry paper resists until the neighbour is quite wet; rougher paper resists more.
        if (c.r < 0.012 && q.r < 0.05 + 0.24 * (1.0 - pc)) f = 0.0;
        if (q.r < 0.012 && c.r < 0.05 + 0.24 * (1.0 - pq)) f = 0.0;
        dw += f;
        dp += f > 0.0 ? f * (q.gba / max(q.r, 0.03)) : f * rhoC;
      }
      float w = c.r + dw;
      vec3 s = max(c.gba + dp, 0.0);
      float front = smoothstep(0.2, 0.0, w) * step(0.0005, w);
      w = max(0.0, w - u_dt * (0.05 + 0.32 * front));
      float rate = clamp(u_dt * (0.15 + 3.0 * smoothstep(0.26, 0.0, w)), 0.0, 1.0);
      if (w < 0.003) rate = 1.0;
      vec3 dep = s * rate;
      vec3 lift = d.rgb * clamp(max(dw, 0.0) * 4.0 * step(0.04, w), 0.0, 0.3);
      o0 = vec4(w, s - dep + lift);
      o1 = vec4(d.rgb + dep - lift, 0.0);
    }`;
    
    /** Paper, the resting wash and the ink, mixed subtractively (absorbances add). */
    const COMPOSITE = /* glsl */ `
    uniform sampler2D u_s0;
    uniform sampler2D u_s1;
    uniform vec2 u_texel;
    uniform vec2 u_res;
    uniform vec3 u_paper;
    uniform float u_time;
    uniform float u_fade;
    uniform float u_resting;
    uniform vec4 u_quiet;
    uniform vec3 u_wash0;
    uniform vec3 u_wash1;
    uniform vec3 u_wash2;
    out vec4 fragColor;
    ${NOISE}
    vec3 washAt(float t) {
      t = fract(t) * 3.0;
      return t < 1.0 ? mix(u_wash0, u_wash1, t) : t < 2.0 ? mix(u_wash1, u_wash2, t - 1.0) : mix(u_wash2, u_wash0, t - 2.0);
    }
    void main() {
      vec2 uv = v_uv;
      vec4 s = texture(u_s0, uv);
      vec3 dep = texture(u_s1, uv).rgb;
      // Deposits denser than their surroundings read as a darker rim.
      vec3 around = (texture(u_s1, uv + vec2(u_texel.x * 2.0, 0.0)).rgb + texture(u_s1, uv - vec2(u_texel.x * 2.0, 0.0)).rgb + texture(u_s1, uv + vec2(0.0, u_texel.y * 2.0)).rgb + texture(u_s1, uv - vec2(0.0, u_texel.y * 2.0)).rgb) * 0.25;
      vec3 rim = max(dep - around, 0.0) * 1.6;
      vec3 a = (s.gba * 0.8 + dep + rim) * (1.0 + 0.3 * s.r) * u_fade;
      if (u_resting > 0.0) {
        float x = uv.x;
        float line = 0.22 + 0.022 * sin(x * 6.3 + u_time * 0.12) + 0.05 * (fbm(vec2(x * 4.0, u_time * 0.03)) - 0.5);
        float depth = line - uv.y;
        float band = smoothstep(-0.002, 0.07, depth);
        float gran = fbm(uv * u_res / 26.0);
        vec3 col = washAt(x * 0.55 + u_time * 0.01 + 0.08 * fbm(uv * 3.0));
        a += col * band * (0.5 + 0.35 * gran) * u_resting;
        a += col * smoothstep(0.01, 0.0, abs(depth - 0.006)) * 0.45 * u_resting;
        float fringe = smoothstep(0.0, -0.012, depth) * smoothstep(-0.034, -0.014, depth);
        a += vec3(0.16, 0.035, 0.0) * fringe * u_resting;
      }
      // Lighter ink under the link columns so the links stay easy to read.
      float q = smoothstep(0.0, 0.035, min(min(uv.x - u_quiet.x, u_quiet.z - uv.x), min(uv.y - u_quiet.y, u_quiet.w - uv.y)));
      a *= mix(1.0, 0.32, q);
      float grain = vnoise(uv * u_res / 1.7);
      fragColor = vec4(u_paper * exp(-a) * (0.975 + 0.035 * grain), 1.0);
    }`;
    
    /** Absorbance of an ink: how much of each channel a unit of it takes out of white paper. */
    const absorb = (hex: string, strength = 1): [number, number, number] => hexToRgb(hex).map((v) => -Math.log(Math.max(0.03, v / 255)) * strength) as [number, number, number];
    
    /* --------------------------------------------------------------------------------- component */
    
    /**
     * A site footer on warm paper where dragging in the lower half drops ink that blooms with feathered,
     * cauliflower edges behind a giant wordmark. A watercolor wash rests along the bottom with a wavy
     * waterline, a pale cyan fringe and slowly drifting color. Strokes deposit water and pigment that
     * spread for a few seconds through paper texture, darken at their rims as they dry and leave pale
     * back-runs where fresh water meets drying pigment; each stroke takes the next ink and colors mix
     * where they meet. "Clear ink" fades it all away.
     *
     * A WebGL2 simulation with half-float ping-pong targets at reduced resolution (src/lib/webgl/ping-pong),
     * composited at full resolution; it stops stepping once everything is dry, pauses offscreen and in
     * hidden tabs and caps DPR at 2. Without WebGL2 or float targets, on software GPUs and under reduced
     * motion, strokes stamp SVG bloom blobs over a static wash. A normal footer with real links that
     * stay above the ink at 4.5:1.
     */
    export function InkBloomFooter({
      wordmark = "Fernwick",
      quote = { text: "I only meant to check the pricing page. Forty minutes later I was still dropping ink at the bottom of it.", cite: "A reader, probably" },
      columns = DEFAULT_COLUMNS,
      inks = INKS,
      resting = true,
      paper = "#f6f1e6",
      note = "© 2026 Fernwick Paper Co.",
      onInk,
      className,
      style,
      ...props
    }: InkBloomFooterProps) {
      const id = useId().replace(/:/g, "");
      const reduceMotion = useReducedMotion();
      const footerRef = useRef<HTMLElement>(null);
      const canvasRef = useRef<HTMLCanvasElement>(null);
      const inkAreaRef = useRef<HTMLDivElement>(null);
      const columnsRef = useRef<HTMLElement>(null);
      const blobsRef = useRef<HTMLDivElement>(null);
      const [hasInk, setHasInk] = useState(false);
      const engine = useRef<{ clear: () => void } | null>(null);
      const live = useRef({ inks, onInk, resting });
      useEffect(() => {
        live.current = { inks, onInk, resting };
      });
    
      useEffect(() => {
        const footer = footerRef.current;
        const canvas = canvasRef.current;
        const area = inkAreaRef.current;
        if (!footer || !canvas || !area) return;
        const rng = createRandom(`ink:${id}`);
        let strokes = 0;
        let drawing = false;
        let last: { x: number; y: number } | null = null;
        const queue: number[] = [];
        let currentInk = live.current.inks[0] ?? INKS[0];
        let activeUntil = 0;
        let addStamp: (x: number, y: number, r: number, strength: number) => void = () => {};
        let clearAll: () => void = () => {};
        let disposeGl = () => {};
    
        /* ------------------------------------------------------------ WebGL path */
        const gl = reduceMotion ? null : canvas.getContext("webgl2", { alpha: false, antialias: false, depth: false, stencil: false, premultipliedAlpha: false, powerPreference: "low-power" });
        const format = gl && !isSoftwareRenderer(gl) ? halfFloatFormat(gl) : null;
        const passes = gl && format ? { splat: createPass(gl, SPLAT), update: createPass(gl, UPDATE), composite: createPass(gl, COMPOSITE) } : null;
        const usable = !!(gl && format && passes?.splat && passes.update && passes.composite);
        // Which renderer is in use, for CSS: the canvas or the static wash and SVG blobs.
        footer.dataset.ink = usable ? "gl" : "fallback";
    
        if (usable && gl && format && passes) {
          const { splat, update, composite } = passes as { splat: Pass; update: Pass; composite: Pass };
          let sim: PingPong | null = null;
          let simW = 1;
          let simH = 1;
          let width = 1;
          let height = 1;
          let time = 0;
          let lastDraw = 0;
          let fade = 1;
          const quiet = [2, 2, 2, 2];
          const paperRgb = hexToRgb(paper).map((v) => v / 255);
    
          const measureQuiet = () => {
            const cols = columnsRef.current;
            if (!cols) return;
            const f = footer.getBoundingClientRect();
            const b = cols.getBoundingClientRect();
            quiet[0] = (b.left - f.left - 12) / f.width;
            quiet[2] = (b.right - f.left + 12) / f.width;
            quiet[1] = 1 - (b.bottom - f.top + 12) / f.height;
            quiet[3] = 1 - (b.top - f.top - 12) / f.height;
          };
    
          addStamp = (x, y, r, strength) => {
            if (queue.length > MAX_STAMPS * 4 * 6) return;
            queue.push(x / width, 1 - y / height, r / height, strength);
            activeUntil = performance.now() + 12000;
            loop.wake();
          };
          clearAll = () => {
            animate(1, 0, {
              duration: 0.6,
              ease: "easeOut",
              onUpdate: (v) => {
                fade = v;
                loop.redraw();
              },
              onComplete: () => {
                sim?.clear();
                queue.length = 0;
                fade = 1;
                loop.redraw();
              },
            });
          };
    
          // The simulation runs at a fixed 90 steps per second, so ink spreads at the same pace on any display.
          const STEPS_PER_SECOND = 90;
          let carry = 0;
          const step = (dt: number) => {
            if (!sim) return;
            const s = sim;
            if (queue.length) {
              const n = Math.min(MAX_STAMPS, queue.length / 4);
              const stamps = new Float32Array(MAX_STAMPS * 4);
              stamps.set(queue.splice(0, n * 4));
              const ink = absorb(currentInk, 0.42);
              splat.draw(s.write, (p) => {
                bindTexture(gl, p, "u_s0", s.read.textures[0], 0);
                bindTexture(gl, p, "u_s1", s.read.textures[1], 1);
                gl.uniform4fv(p.uniform("u_stamps"), stamps);
                gl.uniform1i(p.uniform("u_count"), n);
                gl.uniform3fv(p.uniform("u_ink"), ink);
                gl.uniform1f(p.uniform("u_aspect"), width / height);
              });
              s.swap();
            }
            carry += Math.min(0.1, dt) * STEPS_PER_SECOND;
            const steps = Math.min(8, Math.floor(carry));
            carry -= steps;
            for (let k = 0; k < steps; k++) {
              update.draw(s.write, (p) => {
                bindTexture(gl, p, "u_s0", s.read.textures[0], 0);
                bindTexture(gl, p, "u_s1", s.read.textures[1], 1);
                gl.uniform2f(p.uniform("u_texel"), 1 / simW, 1 / simH);
                gl.uniform2f(p.uniform("u_size"), simW, simH);
                gl.uniform1f(p.uniform("u_dt"), 1 / STEPS_PER_SECOND);
              });
              s.swap();
            }
          };
    
          const draw = () => {
            if (!sim) return;
            const s = sim;
            const wash = live.current.inks.slice(0, 3);
            while (wash.length < 3) wash.push(INKS[wash.length]);
            composite.draw(null, (p) => {
              bindTexture(gl, p, "u_s0", s.read.textures[0], 0);
              bindTexture(gl, p, "u_s1", s.read.textures[1], 1);
              gl.uniform2f(p.uniform("u_texel"), 1 / simW, 1 / simH);
              gl.uniform2f(p.uniform("u_res"), canvas.width, canvas.height);
              gl.uniform3fv(p.uniform("u_paper"), paperRgb);
              gl.uniform1f(p.uniform("u_time"), time);
              gl.uniform1f(p.uniform("u_fade"), fade);
              gl.uniform1f(p.uniform("u_resting"), live.current.resting ? 1 : 0);
              gl.uniform4f(p.uniform("u_quiet"), quiet[0], quiet[1], quiet[2], quiet[3]);
              gl.uniform3fv(p.uniform("u_wash0"), absorb(wash[0], 0.42));
              gl.uniform3fv(p.uniform("u_wash1"), absorb(wash[1], 0.42));
              gl.uniform3fv(p.uniform("u_wash2"), absorb(wash[2], 0.42));
            });
          };
    
          const loop = runCanvasLoop({
            host: footer,
            canvas,
            // Without a resting wash there is nothing to draw once the ink is dry: stop until the next stroke.
            isPaused: () => !live.current.resting && performance.now() > activeUntil && fade === 1,
            resize: ({ width: w, height: h, dpr }) => {
              width = w;
              height = h;
              // Simulate at about a third of the display resolution; composite at full.
              const scale = Math.min(1, 420 / Math.max(w, h)) * Math.min(dpr, 1.5);
              simW = Math.max(16, Math.round(w * scale * 0.85));
              simH = Math.max(16, Math.round(h * scale * 0.85));
              if (sim) sim.resize(simW, simH);
              else sim = createPingPong(gl, simW, simH, format, 2);
              measureQuiet();
            },
            render: ({ dt }) => {
              const now = performance.now();
              const wet = now < activeUntil;
              if (dt > 0) time += dt;
              if (wet && dt > 0) step(dt);
              // While dry, only the resting wash drifts: redraw it about 12 times a second.
              if (dt > 0 && !wet && (!live.current.resting || now - lastDraw < 80)) return;
              lastDraw = now;
              draw();
            },
          });
          disposeGl = () => {
            loop.dispose();
            sim?.dispose();
            splat.dispose();
            update.dispose();
            composite.dispose();
          };
        } else {
          /* ---------------------------------------------------------- fallback path */
          addStamp = (x, y, r) => {
            const host = blobsRef.current;
            if (!host || host.childElementCount > 160) return;
            const ns = "http://www.w3.org/2000/svg";
            const svg = document.createElementNS(ns, "svg");
            const size = r * 3.2;
            svg.setAttribute("viewBox", "-50 -50 100 100");
            svg.setAttribute("aria-hidden", "true");
            Object.assign(svg.style, { position: "absolute", left: `${x - size / 2}px`, top: `${y - size / 2}px`, width: `${size}px`, height: `${size}px`, overflow: "visible", opacity: "0", transition: "opacity 600ms ease-out", mixBlendMode: "multiply" });
            // A flat wash of the ink with a darker rim, roughened into a bloom by the turbulence filter.
            const circle = document.createElementNS(ns, "circle");
            circle.setAttribute("r", String(28 + rng.range(-4, 6)));
            circle.setAttribute("fill", currentInk);
            circle.setAttribute("fill-opacity", "0.34");
            circle.setAttribute("stroke", currentInk);
            circle.setAttribute("stroke-opacity", "0.6");
            circle.setAttribute("stroke-width", "2.5");
            circle.setAttribute("filter", `url(#${id}-bloom)`);
            svg.appendChild(circle);
            host.appendChild(svg);
            requestAnimationFrame(() => (svg.style.opacity = "0.62"));
          };
          clearAll = () => {
            const host = blobsRef.current;
            if (!host) return;
            host.style.transition = "opacity 600ms ease-out";
            host.style.opacity = "0";
            window.setTimeout(() => {
              host.replaceChildren();
              host.style.transition = "";
              host.style.opacity = "1";
            }, 620);
          };
        }
    
        /* ------------------------------------------------------------ input */
        const local = (e: PointerEvent) => {
          const b = footer.getBoundingClientRect();
          return { x: e.clientX - b.left, y: e.clientY - b.top };
        };
        const stampAlong = (to: { x: number; y: number }) => {
          const from = last ?? to;
          const dist = Math.hypot(to.x - from.x, to.y - from.y);
          const spacing = usable ? 4.5 : 22;
          const n = Math.max(1, Math.floor(dist / spacing));
          for (let i = 1; i <= n; i++) {
            const t = i / n;
            addStamp(from.x + (to.x - from.x) * t, from.y + (to.y - from.y) * t, rng.range(20, 32), rng.range(0.26, 0.48));
          }
          last = to;
        };
        const begin = (p: { x: number; y: number }) => {
          drawing = true;
          last = null;
          const list = live.current.inks.length ? live.current.inks : INKS;
          currentInk = list[strokes % list.length];
          strokes += 1;
          live.current.onInk?.(currentInk);
          setHasInk(true);
          stampAlong(p);
        };
        let pending: { x: number; y: number; id: number; timer: number } | null = null;
        let holdBlock = false;
        const onTouchMove = (e: TouchEvent) => {
          if (holdBlock) e.preventDefault();
        };
        const onDown = (e: PointerEvent) => {
          if (e.button !== 0 || (e.target as Element).closest("a, button, input, textarea, select")) return;
          const p = local(e);
          if (e.pointerType === "touch") {
            // On touch, a vertical drag keeps scrolling the page. A horizontal drag, or a 150ms hold, draws.
            pending = {
              ...p,
              id: e.pointerId,
              timer: window.setTimeout(() => {
                if (!pending) return;
                holdBlock = true;
                begin({ x: pending.x, y: pending.y });
                pending = null;
              }, 150),
            };
            return;
          }
          area.setPointerCapture(e.pointerId);
          begin(p);
        };
        const onMove = (e: PointerEvent) => {
          const p = local(e);
          if (pending && e.pointerId === pending.id) {
            const dx = p.x - pending.x;
            const dy = p.y - pending.y;
            if (Math.abs(dx) > 8 && Math.abs(dx) > Math.abs(dy)) {
              window.clearTimeout(pending.timer);
              const start = { x: pending.x, y: pending.y };
              pending = null;
              begin(start);
            } else if (Math.abs(dy) > 8) {
              window.clearTimeout(pending.timer);
              pending = null;
            }
          }
          if (drawing) stampAlong(p);
        };
        const onUp = () => {
          if (pending) window.clearTimeout(pending.timer);
          pending = null;
          drawing = false;
          holdBlock = false;
          last = null;
        };
        area.addEventListener("pointerdown", onDown);
        area.addEventListener("pointermove", onMove);
        area.addEventListener("pointerup", onUp);
        area.addEventListener("pointercancel", onUp);
        area.addEventListener("touchmove", onTouchMove, { passive: false });
        engine.current = { clear: () => clearAll() };
    
        return () => {
          engine.current = null;
          if (pending) window.clearTimeout(pending.timer);
          area.removeEventListener("pointerdown", onDown);
          area.removeEventListener("pointermove", onMove);
          area.removeEventListener("pointerup", onUp);
          area.removeEventListener("pointercancel", onUp);
          area.removeEventListener("touchmove", onTouchMove);
          disposeGl();
        };
      }, [id, paper, reduceMotion]);
    
      const clear = () => {
        engine.current?.clear();
        setHasInk(false);
      };
    
      const halo = { textShadow: `0 0 6px ${paper}, 0 0 14px ${paper}, 0 0 2px ${paper}` } as CSSProperties;
      const [w0, w1, w2] = [inks[0] ?? INKS[0], inks[1] ?? INKS[1], inks[2] ?? INKS[2]];
    
      return (
        <footer ref={footerRef} className={cn("group/ink @container relative isolate w-full overflow-hidden text-[#1c1917]", className)} style={{ backgroundColor: paper, ...style }} {...props}>
          <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10 hidden size-full group-data-[ink=gl]/ink:block" />
    
          {/* Fallback: a static wash and SVG bloom blobs. */}
          <svg aria-hidden="true" className="absolute size-0">
            <defs>
              <filter id={`${id}-bloom`} x="-40%" y="-40%" width="180%" height="180%">
                <feTurbulence type="fractalNoise" baseFrequency="0.035" numOctaves="3" seed="4" result="noise" />
                <feDisplacementMap in="SourceGraphic" in2="noise" scale="26" xChannelSelector="R" yChannelSelector="G" />
              </filter>
              <linearGradient id={`${id}-wash`} x1="0" x2="1">
                <stop offset="0%" stopColor={w0} />
                <stop offset="50%" stopColor={w1} />
                <stop offset="100%" stopColor={w2} />
              </linearGradient>
            </defs>
          </svg>
          {resting ? (
            <svg aria-hidden="true" viewBox="0 0 1000 200" preserveAspectRatio="none" className="pointer-events-none absolute inset-x-0 bottom-0 -z-10 hidden h-[24%] w-full overflow-visible group-data-[ink=fallback]/ink:block">
              <path d="M-60 40 L0 40 C120 18 220 52 340 34 S560 14 680 38 S880 58 1000 30 L1060 30 V280 H-60Z" fill={`url(#${id}-wash)`} opacity="0.42" filter={`url(#${id}-bloom)`} />
              <path d="M0 40 C120 18 220 52 340 34 S560 14 680 38 S880 58 1000 30" fill="none" stroke="#7fd6e6" strokeOpacity="0.55" strokeWidth="3" filter={`url(#${id}-bloom)`} />
            </svg>
          ) : null}
          <div ref={blobsRef} aria-hidden="true" className="pointer-events-none absolute inset-0 -z-10" />
    
          <div className="relative px-6 pt-16 pb-8 @3xl:px-12 @3xl:pt-20">
            {quote ? (
              <figure className="mx-auto max-w-2xl text-center">
                <blockquote className="font-display text-[clamp(1.35rem,3.2cqi,2rem)] leading-snug text-balance" style={halo}>
                  “{quote.text}”
                </blockquote>
                <figcaption className="mt-4 text-[14px] text-[#57534e]" style={halo}>
                  {quote.cite}
                </figcaption>
              </figure>
            ) : null}
    
            {/* The ink area: everything from here down. Links stay clickable; anywhere else draws. */}
            <div ref={inkAreaRef} className="relative mt-16 touch-pan-y select-none @3xl:mt-24">
              <div className="grid gap-10 @3xl:grid-cols-[1fr_auto] @3xl:items-start">
                <div style={halo}>
                  <p className="font-display text-[clamp(1.75rem,4cqi,2.5rem)] leading-tight font-bold">Leave a mark.</p>
                  <p className="mt-2 text-[15px] text-[#57534e]">Drag anywhere down here to drop ink.</p>
                  <AnimatePresence>
                    {hasInk ? (
                      <motion.button
                        type="button"
                        onClick={clear}
                        initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 6 }}
                        animate={{ opacity: 1, y: 0 }}
                        exit={{ opacity: 0 }}
                        transition={{ duration: 0.2 }}
                        className="mt-5 inline-flex h-10 cursor-pointer items-center gap-2 rounded-md border border-[#8a827c] bg-[#f6f1e6]/80 px-3.5 text-[13px] font-semibold text-[#1c1917] outline-none hover:bg-[#ece6d8] focus-visible:ring-2 focus-visible:ring-[#c2410c] active:scale-[0.96]"
                      >
                        <Eraser className="size-3.5" aria-hidden="true" />
                        Clear ink
                      </motion.button>
                    ) : null}
                  </AnimatePresence>
                </div>
                <nav ref={columnsRef} aria-label="Footer" className="grid grid-cols-2 gap-x-12 gap-y-8 @lg:grid-cols-3">
                  {columns.map((col) => (
                    <div key={col.title}>
                      <p className="text-[11px] font-semibold tracking-[0.06em] text-[#57534e] uppercase" style={halo}>
                        {col.title}
                      </p>
                      <ul className="mt-3 space-y-2">
                        {col.links.map((link) => (
                          <li key={link.label}>
                            <a href={link.href} className="rounded-sm text-[15px] text-[#1c1917] underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-[#c2410c]" style={halo}>
                              {link.label}
                            </a>
                          </li>
                        ))}
                      </ul>
                    </div>
                  ))}
                </nav>
              </div>
    
              <div className="mt-20 flex flex-col gap-4 @3xl:mt-28 @3xl:flex-row @3xl:items-end @3xl:justify-between">
                <p className="font-display text-[clamp(3.5rem,15cqi,11rem)] leading-[0.8] font-bold tracking-[-0.03em] text-[#1c1917]">{wordmark}</p>
                {note ? (
                  <p className="text-[13px] text-[#57534e] @3xl:pb-3" style={halo}>
                    {note}
                  </p>
                ) : null}
              </div>
            </div>
          </div>
        </footer>
      );
    }

Usage

import { InkBloomFooter } from "@/components/ui/ink-bloom-footer/ink-bloom-footer";

<InkBloomFooter
  wordmark="Fernwick"
  quote={{ text: "I only meant to check the pricing page.", cite: "A reader, probably" }}
  columns={[
    { title: "Product", links: [{ label: "Pricing", href: "/pricing" }] },
    { title: "Help", links: [{ label: "Support", href: "/support" }] },
  ]}
  inks={["#f2b705", "#e0457b", "#3b3a98"]}
/>

API reference

PropTypeDefaultDescription
wordmarkReactNode"Fernwick"The giant wordmark, bottom left.
quote{ text: ReactNode; cite: ReactNode } | null–Centered testimonial at the top. Pass null to leave it out.
columnsInkBloomColumn[]Product, Company and HelpLink columns, middle right.
inksstring[]yellow, magenta and indigoInk colors (hex); each new stroke takes the next.
restingbooleantrueA watercolor wash already resting along the bottom.
paperstring"#f6f1e6"Paper color behind everything (hex).
noteReactNode–Small line bottom right, e.g. the copyright.
onInk(color: string) => void–Called with the color of each new stroke.

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

  • The ink is a small watercolor simulation in WebGL2 with half-float ping-pong targets (`src/lib/webgl/ping-pong.ts`, shipped with the component): one texture holds water and suspended pigment, another the deposited pigment. Each step, water flows to drier neighbours through paper whose permeability comes from noise, so wet fronts branch into cauliflower edges; evaporation is fastest at the thin front, so pigment is carried outward and settles there as a darker rim; fresh water pushing into drying pigment lifts some of it again and leaves pale back-runs.
  • Pigments are stored as absorbance, so they mix subtractively where strokes meet. The simulation runs at about a third of the display resolution and is composited at full resolution over the paper with the resting wash: a wavy, feathered waterline with a pale cyan fringe whose colors drift slowly across.
  • It stops stepping a few seconds after the last stroke (only the resting wash is redrawn, about 12 times a second), pauses offscreen and in hidden tabs, and caps DPR at 2. Clear ink fades everything out over 600ms.
  • Without WebGL2 or renderable float textures, on software GPUs and under reduced motion, strokes stamp SVG blooms (feTurbulence + feDisplacementMap) that fade in without spreading over a static wash.

Accessibility

  • A normal footer element with a labelled navigation and real links; the ink canvas and the fallback blobs are aria-hidden decoration.
  • Links and text stay above the ink at 4.5:1: they carry a soft paper-colored halo, and the ink is lighter under the link columns. The wordmark is solid ink on top.
  • On touch screens a vertical drag keeps scrolling the page; a horizontal drag or a 150ms hold draws. Clicks on links and buttons never draw.
  • Under reduced motion the ink doesn't spread: strokes stamp still blooms and the resting wash is a static image.