Skip to content

Ripple Form

A live sign-up form on a rippling water surface.

Experimental · Chrome origin trialChecking support…

Built on the WICG HTML-in-Canvas proposal, which is not a web standard and changes between Chrome versions. Every component works without it: other browsers get the same live DOM with a CSS approximation of the effect.

To try the native version locally, open Chrome Canary and enable chrome://flags/#canvas-draw-element. In production, register for the origin trial and set NEXT_PUBLIC_HTML_IN_CANVAS_OT_TOKEN.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/ripple-form.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/html-canvas/gl.ts
    /**
     * Small WebGL2 helpers shared by the HTML-in-Canvas components. Shaders work in the canvas's CSS
     * pixels, so a DOMMatrix used to draw an element is the same one passed to `syncElementGeometry`.
     */
    
    /**
     * GLSL: projects a CSS-px position (already multiplied by the draw matrix, so it may carry a
     * perspective w) to clip space. Keeps w for perspective-correct texturing. Needs `uniform vec2 u_canvas`.
     */
    export const PROJECT_GLSL = /* glsl */ `
    uniform vec2 u_canvas;
    vec4 projectCss(vec4 q) {
      return vec4(2.0 * q.x / u_canvas.x - q.w, -(2.0 * q.y / u_canvas.y - q.w), 0.0, q.w);
    }`;
    
    /** GLSL: signed distance to a rounded rectangle centered at the origin (half size `b`, radius `r`). */
    export const ROUND_RECT_GLSL = /* glsl */ `
    float sdRoundRect(vec2 p, vec2 b, float r) {
      vec2 q = abs(p) - b + r;
      return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
    }`;
    
    function compile(gl: WebGL2RenderingContext, type: number, source: string) {
      const shader = gl.createShader(type);
      if (!shader) throw new Error("Could not create a shader");
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS) && !gl.isContextLost()) {
        const log = gl.getShaderInfoLog(shader);
        gl.deleteShader(shader);
        throw new Error(`Shader compile failed: ${log}`);
      }
      return shader;
    }
    
    export interface Program {
      program: WebGLProgram;
      /** Cached uniform location lookup. */
      u: (name: string) => WebGLUniformLocation | null;
    }
    
    /** Compiles and links a program. Attribute 0 is always `a_pos`. */
    export function createProgram(gl: WebGL2RenderingContext, vertex: string, fragment: string): Program {
      const program = gl.createProgram();
      if (!program) throw new Error("Could not create a program");
      gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, vertex));
      gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, fragment));
      gl.bindAttribLocation(program, 0, "a_pos");
      gl.linkProgram(program);
      if (!gl.getProgramParameter(program, gl.LINK_STATUS) && !gl.isContextLost()) throw new Error(`Program link failed: ${gl.getProgramInfoLog(program)}`);
      const cache = new Map<string, WebGLUniformLocation | null>();
      return {
        program,
        u: (name) => {
          if (!cache.has(name)) cache.set(name, gl.getUniformLocation(program, name));
          return cache.get(name) ?? null;
        },
      };
    }
    
    export interface Mesh {
      vao: WebGLVertexArrayObject;
      count: number;
    }
    
    /** A unit grid over [0, 1]² as indexed triangles, in attribute 0. One cell is a plain quad. */
    export function createGrid(gl: WebGL2RenderingContext, cols = 1, rows = 1): Mesh {
      const vao = gl.createVertexArray();
      if (!vao) throw new Error("Could not create a vertex array");
      gl.bindVertexArray(vao);
      const positions: number[] = [];
      for (let y = 0; y <= rows; y++) for (let x = 0; x <= cols; x++) positions.push(x / cols, y / rows);
      const indices: number[] = [];
      for (let y = 0; y < rows; y++) {
        for (let x = 0; x < cols; x++) {
          const i = y * (cols + 1) + x;
          indices.push(i, i + 1, i + cols + 1, i + 1, i + cols + 2, i + cols + 1);
        }
      }
      const vbo = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, vbo);
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(positions), gl.STATIC_DRAW);
      gl.enableVertexAttribArray(0);
      gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
      const ibo = gl.createBuffer();
      gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo);
      gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint32Array(indices), gl.STATIC_DRAW);
      gl.bindVertexArray(null);
      return { vao, count: indices.length };
    }
    
    /** Draws a mesh from `createGrid`. */
    export function drawMesh(gl: WebGL2RenderingContext, mesh: Mesh, instances = 0) {
      gl.bindVertexArray(mesh.vao);
      if (instances > 0) gl.drawElementsInstanced(gl.TRIANGLES, mesh.count, gl.UNSIGNED_INT, 0, instances);
      else gl.drawElements(gl.TRIANGLES, mesh.count, gl.UNSIGNED_INT, 0);
      gl.bindVertexArray(null);
    }
    
    /** Uploads a DOMMatrix to a mat4 uniform. */
    export function setMatrix(gl: WebGL2RenderingContext, location: WebGLUniformLocation | null, matrix: DOMMatrix) {
      gl.uniformMatrix4fv(location, false, matrix.toFloat32Array());
    }
    
    /** Parses "#rrggbb" into 0–1 RGB. */
    export function rgb(hex: string): [number, number, number] {
      const h = hex.replace("#", "");
      const n = Number.parseInt(h.length === 3 ? h.replace(/./g, (c) => c + c) : h, 16) || 0;
      return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];
    }
    lib/html-canvas/setup.ts
    import type { HtmlCanvasSupport } from "@/lib/html-canvas/support";
    
    /* eslint-disable @typescript-eslint/no-explicit-any -- the API is not in lib.dom yet */
    
    /**
     * Attributes that make a canvas lay out and paint its children, for use in JSX so the canvas is
     * drawable from its first layout: `content="drawable"` on 155+, `layoutsubtree` before that.
     */
    export function drawableCanvasAttributes(support: HtmlCanvasSupport): Record<string, string> {
      if (support.version === "155+") return { content: "drawable" };
      if (support.version === "none") return {};
      return { layoutsubtree: "" };
    }
    
    /** Makes a canvas drawable after the fact (idempotent). */
    export function prepareDrawableCanvas(canvas: HTMLCanvasElement) {
      const c = canvas as any;
      if ("content" in HTMLCanvasElement.prototype) {
        if (canvas.getAttribute("content") !== "drawable") canvas.setAttribute("content", "drawable");
      } else if (!canvas.hasAttribute("layoutsubtree")) {
        canvas.setAttribute("layoutsubtree", "");
        if ("layoutSubtree" in c) c.layoutSubtree = true;
      }
    }
    
    /**
     * Attributes for an element the canvas draws on its own. On 155+ it must carry `drawable`; on older
     * versions it must instead be a direct child of the canvas, which callers arrange in their markup.
     */
    export function drawableElementAttributes(support: HtmlCanvasSupport): Record<string, string> {
      return support.version === "155+" ? { drawable: "" } : {};
    }
    
    /** Asks the canvas for a `paint` event on the next frame, where supported. */
    export function requestCanvasPaint(canvas: HTMLCanvasElement) {
      const c = canvas as any;
      if (typeof c.requestPaint === "function") c.requestPaint();
    }
    /* eslint-enable @typescript-eslint/no-explicit-any */
    lib/html-canvas/support.ts
    /**
     * Feature detection for the experimental HTML-in-Canvas API (WICG html-in-canvas). The API is a
     * Chrome origin trial, not a standard, and its shape changed in Chrome 150 and 155. Nothing outside
     * `src/lib/html-canvas/` may touch it: every call goes through these adapters.
     *
     * - 155+: `content="drawable"`, `drawable` descendants, `texElementSubImage2D`,
     *   `canvas.updateElementGeometry()` and automatic hit-testing for 2D draws.
     * - 150–154: `layoutsubtree`, `drawElementImage()` returning a DOMMatrix, `texElementImage2D(target,
     *   internalformat, element)` and `canvas.getElementTransform(element, drawTransform)`.
     * - legacy (< 150): `texElementImage2D(target, level, internalformat, format, type, element)`.
     */
    
    export type HtmlCanvasVersion = "155+" | "150-154" | "legacy" | "none";
    
    export interface HtmlCanvasSupport {
      /** `drawElementImage` on 2D contexts. */
      twoD: boolean;
      /** Element textures in WebGL2. */
      webgl: boolean;
      /** Element textures in WebGPU. */
      webgpu: boolean;
      version: HtmlCanvasVersion;
    }
    
    export const NO_SUPPORT: HtmlCanvasSupport = { twoD: false, webgl: false, webgpu: false, version: "none" };
    
    /* eslint-disable @typescript-eslint/no-explicit-any -- the API is not in lib.dom yet */
    let cached: HtmlCanvasSupport | null = null;
    
    /** What this browser supports. Always "none" on the server. Cached after the first call. */
    export function getHtmlCanvasSupport(): HtmlCanvasSupport {
      if (typeof window === "undefined" || typeof HTMLCanvasElement === "undefined") return NO_SUPPORT;
      if (cached) return cached;
      const canvasProto = HTMLCanvasElement.prototype as any;
      const ctx2d = typeof CanvasRenderingContext2D !== "undefined" ? (CanvasRenderingContext2D.prototype as any) : {};
      const gl2 = typeof WebGL2RenderingContext !== "undefined" ? (WebGL2RenderingContext.prototype as any) : {};
      const gpuQueue = typeof (window as any).GPUQueue !== "undefined" ? (window as any).GPUQueue.prototype : {};
    
      const twoD = "drawElementImage" in ctx2d;
      const sub = "texElementSubImage2D" in gl2;
      const tex = "texElementImage2D" in gl2;
      const webgpu = "drawElementImageToTexture" in gpuQueue || "copyElementImageToTexture" in gpuQueue;
    
      let version: HtmlCanvasVersion = "none";
      if (sub || "content" in canvasProto || "updateElementGeometry" in canvasProto) version = "155+";
      else if (tex) version = typeof gl2.texElementImage2D === "function" && gl2.texElementImage2D.length >= 6 ? "legacy" : "150-154";
      else if (twoD || "layoutSubtree" in canvasProto) version = "150-154";
    
      cached = { twoD, webgl: sub || tex, webgpu, version };
      return cached;
    }
    
    /** True when the API can draw into the given context type. */
    export function supportsContext(support: HtmlCanvasSupport, context: "2d" | "webgl2") {
      return support.version !== "none" && (context === "2d" ? support.twoD : support.webgl);
    }
    
    /** A short label for status chips: "Chrome 150–154 API". */
    export function describeSupport(support: HtmlCanvasSupport) {
      if (support.version === "none") return "Not supported";
      return { "155+": "Chrome 155+ API", "150-154": "Chrome 150–154 API", legacy: "Pre-150 API" }[support.version];
    }
    /* eslint-enable @typescript-eslint/no-explicit-any */
    lib/html-canvas/sync-geometry.ts
    import { getHtmlCanvasSupport } from "@/lib/html-canvas/support";
    
    /* eslint-disable @typescript-eslint/no-explicit-any -- the API is not in lib.dom yet */
    
    /**
     * Tells the browser where an element is drawn, so clicks, text selection, focus rings and screen
     * reader focus land on the drawn pixels. `canvasTransform` maps the element's own CSS px (origin at
     * its top-left) to the canvas's CSS px, and may include perspective; it is converted to the device
     * pixels the API expects.
     *
     * Canvas children are laid out at the canvas's top-left corner (they do not stack), so the transform
     * is the element's whole position.
     *
     * For non-affine warps (ripples, curls, cloth), pass the closest affine approximation and calm the
     * warp while an input inside the element has focus.
     */
    export function syncElementGeometry(canvas: HTMLCanvasElement, element: HTMLElement, canvasTransform: DOMMatrix) {
      const c = canvas as any;
      const support = getHtmlCanvasSupport();
      const cssWidth = canvas.clientWidth || canvas.width;
      const cssHeight = canvas.clientHeight || canvas.height;
      // The API works in device pixels on both sides (the element's rendered image and the canvas's backing
      // store), so the CSS-px matrix is conjugated by the pixel ratio: S · M · S⁻¹.
      const toBackingStore = new DOMMatrix().scale(canvas.width / cssWidth, canvas.height / cssHeight, 1);
      const pixels = toBackingStore.multiply(canvasTransform).multiply(toBackingStore.inverse());
      try {
        if (support.version === "155+" && typeof c.updateElementGeometry === "function") {
          c.updateElementGeometry(element, { canvasTransform: pixels });
          return;
        }
        if (typeof c.getElementTransform === "function") {
          element.style.transform = c.getElementTransform(element, pixels).toString();
          return;
        }
      } catch {
        // Fall through to the manual mapping below.
      }
      // Manual: children sit at the canvas origin, so the CSS-px transform applies as is.
      element.style.transformOrigin = "0 0";
      element.style.transform = canvasTransform.toString();
    }
    
    /** Clears geometry set by `syncElementGeometry` or a legacy `drawElement2D`. */
    export function clearElementGeometry(element: HTMLElement) {
      element.style.transform = "";
      element.style.transformOrigin = "";
    }
    
    /** A CSS-px perspective matrix, like `perspective(d)` in CSS. */
    export function perspective(distance: number) {
      return new DOMMatrix([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, -1 / distance, 0, 0, 0, 1]);
    }
    /* eslint-enable @typescript-eslint/no-explicit-any */
    lib/html-canvas/texture-webgl.ts
    import { getHtmlCanvasSupport } from "@/lib/html-canvas/support";
    
    /* eslint-disable @typescript-eslint/no-explicit-any -- the API is not in lib.dom yet */
    
    /** A WebGL texture that holds a live element's pixels. */
    export interface ElementTexture {
      texture: WebGLTexture;
      /** Natural size of the last upload, in CSS px. */
      width: number;
      height: number;
      /** Allocated size on 155+, where the storage is reused until the element resizes. */
      allocated: { width: number; height: number } | null;
    }
    
    /** Creates an empty, linearly filtered, edge-clamped texture for `uploadElementTexture`. */
    export function createElementTexture(gl: WebGL2RenderingContext): ElementTexture {
      const texture = gl.createTexture();
      if (!texture) throw new Error("Could not create a texture");
      gl.bindTexture(gl.TEXTURE_2D, texture);
      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);
      return { texture, width: 0, height: 0, allocated: null };
    }
    
    /**
     * Uploads an element's current rendering into `target`. Call it from the canvas `paint` event or on
     * resize, never every frame. Returns false when the element could not be uploaded.
     *
     * - 155+: measures with `captureElementImage`, allocates once with `texImage2D(…, null)` (again only
     *   when the size changes), then copies with `texElementSubImage2D`.
     * - 150–154: `texElementImage2D(target, internalformat, element)`.
     * - legacy: `texElementImage2D(target, level, internalformat, format, type, element)`.
     */
    export function uploadElementTexture(gl: WebGL2RenderingContext, target: ElementTexture, element: Element) {
      const g = gl as any;
      const support = getHtmlCanvasSupport();
      const box = element.getBoundingClientRect();
      const html = element as HTMLElement;
      // Layout size, unaffected by the CSS transform used for hit-testing.
      const width = html.offsetWidth || box.width;
      const height = html.offsetHeight || box.height;
      if (!width || !height) return false;
      gl.bindTexture(gl.TEXTURE_2D, target.texture);
      try {
        if (support.version === "155+" && typeof g.texElementSubImage2D === "function") {
          const canvas = gl.canvas as any;
          let natural = { width, height };
          if (typeof canvas.captureElementImage === "function") {
            const image = canvas.captureElementImage(element);
            natural = { width: image.width, height: image.height };
            image.close?.();
          }
          if (!target.allocated || target.allocated.width !== natural.width || target.allocated.height !== natural.height) {
            gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, natural.width, natural.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
            target.allocated = natural;
          }
          g.texElementSubImage2D(gl.TEXTURE_2D, 0, 0, 0, element);
        } else if (support.version === "legacy") {
          g.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, element);
        } else if (typeof g.texElementImage2D === "function") {
          g.texElementImage2D(gl.TEXTURE_2D, gl.RGBA8, element);
        } else {
          return false;
        }
      } catch {
        return false;
      }
      target.width = width;
      target.height = height;
      return true;
    }
    /* eslint-enable @typescript-eslint/no-explicit-any */
    lib/html-canvas/use-html-canvas.ts
    "use client";
    
    import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
    import { useReducedMotion } from "motion/react";
    import { drawableCanvasAttributes, prepareDrawableCanvas, requestCanvasPaint } from "@/lib/html-canvas/setup";
    import { getHtmlCanvasSupport, NO_SUPPORT, supportsContext, type HtmlCanvasSupport } from "@/lib/html-canvas/support";
    
    export type HtmlCanvasMode = "auto" | "native" | "fallback";
    type ContextType = "2d" | "webgl2";
    type ContextFor<C extends ContextType> = C extends "2d" ? CanvasRenderingContext2D : WebGL2RenderingContext;
    
    export interface HtmlCanvasFrame<C extends ContextType> {
      ctx: ContextFor<C>;
      canvas: HTMLCanvasElement;
      /** Canvas size in CSS px. */
      width: number;
      height: number;
      /** Device pixels per CSS px used for the backing store (capped). */
      dpr: number;
      /** Seconds since the hook started. */
      time: number;
      /** Seconds since the previous render. */
      delta: number;
      /** Why this render happened: drawn elements repainted, the canvas resized, or an animation frame. */
      reason: "paint" | "resize" | "frame" | "request";
      /** Elements that repainted (from the paint event), when known. */
      changed: readonly Element[];
    }
    
    export interface UseHtmlCanvasOptions<C extends ContextType> {
      /** Context to draw with. */
      context: C;
      /** "auto" uses the API when available, "fallback" never does, "native" requires it. @default "auto" */
      mode?: HtmlCanvasMode;
      /** Run a render every frame while true (e.g. while a ripple is fading). Paused offscreen and in hidden tabs. */
      animating?: boolean;
      /** Called once per context (again after a lost context is restored). Return a cleanup. */
      setup?: (ctx: ContextFor<C>, canvas: HTMLCanvasElement) => void | (() => void);
      /** Draws a frame. Upload element textures only when `reason` is "paint" or "resize". */
      render: (frame: HtmlCanvasFrame<C>) => void;
      /** Highest device pixel ratio for the backing store. @default 2 */
      maxDpr?: number;
      /** Context attributes for WebGL. */
      contextAttributes?: WebGLContextAttributes;
    }
    
    export interface HtmlCanvasController {
      /** True when the canvas path is active; render the fallback DOM otherwise. */
      native: boolean;
      /** What the browser supports (none on the server and during hydration). */
      support: HtmlCanvasSupport;
      /** Ref for the <canvas>. */
      canvasRef: (canvas: HTMLCanvasElement | null) => void;
      /** Spread onto the <canvas> so it lays out and paints its children from the first layout. */
      canvasProps: Record<string, string>;
      /** Asks for one more render (and a browser paint event where supported). */
      invalidate: () => void;
    }
    
    const noop = () => () => {};
    
    /**
     * Ties the HTML-in-Canvas adapter together: decides native vs. fallback (never during SSR or
     * hydration, never under reduced motion), prepares the canvas, renders on the canvas `paint` event
     * and on resize, runs a frame loop only while `animating`, pauses offscreen and in hidden tabs,
     * recovers from a lost WebGL context and cleans up on unmount.
     */
    export function useHtmlCanvas<C extends ContextType>({
      context,
      mode = "auto",
      animating = false,
      setup,
      render,
      maxDpr = 2,
      contextAttributes,
    }: UseHtmlCanvasOptions<C>): HtmlCanvasController {
      const hydrated = useSyncExternalStore(
        noop,
        () => true,
        () => false,
      );
      const reduceMotion = useReducedMotion();
      const support = hydrated ? getHtmlCanvasSupport() : NO_SUPPORT;
      const [failed, setFailed] = useState(false);
      const native = hydrated && !failed && mode !== "fallback" && !reduceMotion && supportsContext(support, context);
    
      // The element lives in a ref (it is mutated: size, attributes); the counter re-runs the effect when it changes.
      const canvasEl = useRef<HTMLCanvasElement | null>(null);
      const [canvasVersion, setCanvasVersion] = useState(0);
      const canvasRef = useCallback((el: HTMLCanvasElement | null) => {
        if (canvasEl.current === el) return;
        canvasEl.current = el;
        setCanvasVersion((v) => v + 1);
      }, []);
      const renderRef = useRef(render);
      const setupRef = useRef(setup);
      const animatingRef = useRef(animating);
      useLayoutEffect(() => {
        renderRef.current = render;
        setupRef.current = setup;
        animatingRef.current = animating;
      });
      const kick = useRef<(reason: HtmlCanvasFrame<C>["reason"]) => void>(() => {});
    
      useEffect(() => {
        const canvas = canvasEl.current;
        if (!native || !canvas) return;
        prepareDrawableCanvas(canvas);
        const ctx = canvas.getContext(context, context === "webgl2" ? { alpha: true, premultipliedAlpha: true, antialias: true, ...contextAttributes } : undefined) as ContextFor<C> | null;
        if (!ctx) {
          // Defer so the state change happens outside the effect body.
          const t = window.setTimeout(() => setFailed(true), 0);
          return () => window.clearTimeout(t);
        }
        let cleanupSetup: void | (() => void);
        let ready = false;
        const runSetup = () => {
          try {
            cleanupSetup = setupRef.current?.(ctx, canvas);
            ready = true;
          } catch (error) {
            console.error(error);
            ready = false;
            window.setTimeout(() => setFailed(true), 0);
          }
        };
        runSetup();
    
        const start = performance.now();
        // Frame time is measured between loop frames only, so the first frame after idling has a normal step.
        let lastFrame = 0;
        let size = { width: 0, height: 0, dpr: 1 };
        let visible = true;
        let frame = 0;
        let changed: readonly Element[] = [];
    
        const draw = (reason: HtmlCanvasFrame<C>["reason"]) => {
          if (!ready || !size.width || !size.height) return;
          const now = performance.now();
          const delta = reason === "frame" && lastFrame ? Math.min(1 / 20, (now - lastFrame) / 1000) : reason === "frame" ? 1 / 60 : 0;
          if (reason === "frame") lastFrame = now;
          try {
            renderRef.current({ ctx, canvas, width: size.width, height: size.height, dpr: size.dpr, time: (now - start) / 1000, delta, reason, changed });
          } catch (error) {
            console.error(error);
          }
          changed = [];
        };
    
        const loop = () => {
          frame = 0;
          if (!visible || document.hidden || !animatingRef.current) {
            lastFrame = 0;
            return;
          }
          draw("frame");
          frame = requestAnimationFrame(loop);
        };
        const ensureLoop = () => {
          if (!frame && visible && !document.hidden && animatingRef.current) frame = requestAnimationFrame(loop);
        };
        kick.current = (reason) => {
          draw(reason);
          ensureLoop();
        };
    
        const resize = () => {
          const rect = canvas.getBoundingClientRect();
          const dpr = Math.min(maxDpr, window.devicePixelRatio || 1);
          const width = Math.max(1, Math.round(rect.width));
          const height = Math.max(1, Math.round(rect.height));
          if (width === size.width && height === size.height && dpr === size.dpr) return;
          size = { width, height, dpr };
          canvas.width = Math.round(width * dpr);
          canvas.height = Math.round(height * dpr);
          draw("resize");
          requestCanvasPaint(canvas);
        };
        const resizeObserver = new ResizeObserver(resize);
        resizeObserver.observe(canvas);
        resize();
    
        const onPaint = (event: Event) => {
          changed = ((event as Event & { changedElements?: Element[] }).changedElements ?? []) as Element[];
          draw("paint");
          ensureLoop();
        };
        canvas.addEventListener("paint", onPaint);
    
        const io = new IntersectionObserver(([entry]) => {
          visible = entry?.isIntersecting ?? true;
          ensureLoop();
        });
        io.observe(canvas);
        const onVisibility = () => ensureLoop();
        document.addEventListener("visibilitychange", onVisibility);
    
        const onLost = (event: Event) => {
          event.preventDefault();
          ready = false;
          cancelAnimationFrame(frame);
          frame = 0;
        };
        const onRestored = () => {
          runSetup();
          draw("resize");
          requestCanvasPaint(canvas);
        };
        canvas.addEventListener("webglcontextlost", onLost);
        canvas.addEventListener("webglcontextrestored", onRestored);
    
        requestCanvasPaint(canvas);
    
        return () => {
          cancelAnimationFrame(frame);
          resizeObserver.disconnect();
          io.disconnect();
          canvas.removeEventListener("paint", onPaint);
          canvas.removeEventListener("webglcontextlost", onLost);
          canvas.removeEventListener("webglcontextrestored", onRestored);
          document.removeEventListener("visibilitychange", onVisibility);
          kick.current = () => {};
          if (typeof cleanupSetup === "function") cleanupSetup();
        };
        // contextAttributes and maxDpr are read once per canvas.
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [native, canvasVersion, context]);
    
      // Start or stop the loop when `animating` flips.
      useEffect(() => {
        if (animating) kick.current("frame");
      }, [animating]);
    
      const invalidate = useCallback(() => {
        kick.current("request");
        if (canvasEl.current) requestCanvasPaint(canvasEl.current);
      }, []);
    
      return { native, support, canvasRef, canvasProps: drawableCanvasAttributes(support), invalidate };
    }
    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Add the theme tokens

    This component uses --destructive-text and --success, which shadcn does not define by default. Add them to your global CSS (--destructive-text falls back to your --destructive unless you set it):

    app/globals.css
    :root {
      --success: #15803d;
    }
    
    .dark {
      --success: #4ade80;
    }
    
    @theme inline {
      --color-destructive-text: var(--destructive-text, var(--destructive));
      --color-success: var(--success);
    }
  4. Copy the source

    components/ui/ripple-form/ripple-form.tsx
    "use client";
    
    import { useEffect, useId, useRef, useState, type FormEvent, type PointerEvent as ReactPointerEvent } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { Check } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration, ease } from "@/lib/motion";
    import { createGrid, createProgram, drawMesh, PROJECT_GLSL, rgb, type Mesh, type Program } from "@/lib/html-canvas/gl";
    import { createElementTexture, uploadElementTexture, type ElementTexture } from "@/lib/html-canvas/texture-webgl";
    import { syncElementGeometry } from "@/lib/html-canvas/sync-geometry";
    import { useHtmlCanvas, type HtmlCanvasMode } from "@/lib/html-canvas/use-html-canvas";
    
    export interface RippleFormValues {
      name: string;
      email: string;
      updates: boolean;
    }
    
    export interface RippleFormProps {
      /** Heading above the fields. @default "Join the waitlist" */
      title?: string;
      /** Line under the heading. */
      description?: string;
      /** Submit button label. @default "Request access" */
      submitLabel?: string;
      /** Message shown after a successful submit. @default "You're on the list." */
      successMessage?: string;
      /** Called with the values on submit. Return a promise to show a pending state; reject to show an error. */
      onSubmit?: (values: RippleFormValues) => void | Promise<void>;
      /** Tint of the water's highlights. @default "#2563eb" */
      waterColor?: string;
      /** Ripple strength, 0–2. @default 1 */
      strength?: number;
      /** "auto" uses HTML-in-Canvas when available, "fallback" never does. @default "auto" */
      rendering?: HtmlCanvasMode;
      /** Called when the rendering mode is known. */
      onModeChange?: (native: boolean) => void;
      /** Classes for the root. */
      className?: string;
    }
    
    const MAX_RIPPLES = 10;
    const RIPPLE_LIFE = 2.6;
    
    const VERT = /* glsl */ `#version 300 es
    in vec2 a_pos;
    uniform vec2 u_size;
    out vec2 v_uv;
    ${PROJECT_GLSL}
    void main() {
      v_uv = a_pos;
      gl_Position = projectCss(vec4(a_pos * u_size, 0.0, 1.0));
    }`;
    
    const FRAG = /* glsl */ `#version 300 es
    precision highp float;
    in vec2 v_uv;
    uniform sampler2D u_tex;
    uniform vec2 u_size;
    uniform float u_time;
    uniform vec4 u_ripples[${MAX_RIPPLES}];
    uniform vec4 u_focus;
    uniform vec3 u_tint;
    uniform float u_strength;
    out vec4 outColor;
    
    void main() {
      vec2 px = v_uv * u_size;
      vec2 disp = vec2(0.0);
      float height = 0.0;
      for (int i = 0; i < ${MAX_RIPPLES}; i++) {
        vec4 r = u_ripples[i];
        float age = u_time - r.z;
        if (r.w <= 0.0 || age < 0.0 || age > ${RIPPLE_LIFE.toFixed(1)}) continue;
        // r.w > 1.5 marks a slow, wide "calm" wave.
        bool calm = r.w > 1.5;
        float speed = calm ? 170.0 : 300.0;
        float width = calm ? 60.0 : 22.0;
        float k = calm ? 0.05 : 0.17;
        vec2 d = px - r.xy;
        float dist = length(d);
        float x = dist - age * speed;
        float env = exp(-x * x / (2.0 * width * width)) * exp(-age * (calm ? 1.1 : 1.8)) * min(r.w, 1.0);
        vec2 dir = dist > 0.001 ? d / dist : vec2(0.0);
        disp += dir * sin(x * k) * env * 7.0;
        height += cos(x * k) * env;
      }
      // Keep the field being typed in crisp: ripples fade out near the focused input.
      if (u_focus.z > 0.0) {
        vec2 c = u_focus.xy + u_focus.zw * 0.5;
        vec2 q = abs(px - c) - u_focus.zw * 0.5;
        float sd = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
        float keep = smoothstep(0.0, 48.0, sd);
        disp *= keep;
        height *= keep;
      }
      disp *= u_strength;
      vec4 color = texture(u_tex, (px + disp) / u_size);
      // Light catches the crests; troughs darken slightly.
      float h = height * u_strength;
      color.rgb += color.a * (u_tint * max(h, 0.0) * 0.10 + vec3(max(h, 0.0) * 0.05) - vec3(max(-h, 0.0) * 0.03));
      outColor = color;
    }`;
    
    type Ripple = { x: number; y: number; t: number; amp: number };
    
    /** Caret position inside a text input, in the input's own px. */
    function caretOffset(input: HTMLInputElement) {
      const style = getComputedStyle(input);
      const ctx = document.createElement("canvas").getContext("2d");
      if (!ctx) return { x: input.clientWidth / 2, y: input.clientHeight / 2 };
      ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
      const before = input.value.slice(0, input.selectionStart ?? input.value.length);
      const x = Number.parseFloat(style.paddingLeft) + ctx.measureText(input.type === "password" ? "•".repeat(before.length) : before).width - input.scrollLeft;
      return { x: Math.min(input.clientWidth - 4, Math.max(4, x)), y: input.clientHeight / 2 };
    }
    
    /**
     * A real sign-up form (labels, inputs, checkbox, submit) rendered through HTML-in-Canvas on a water
     * surface: clicks and keystrokes send ripples from the pointer or the caret, the field being typed in
     * stays crisp, and submitting sends one calm wave. Without the API, or with reduced motion, it is the
     * same form as plain DOM with a ripple on the button.
     */
    export function RippleForm({
      title = "Join the waitlist",
      description = "Early access opens in waves. We'll email you when it's your turn.",
      submitLabel = "Request access",
      successMessage = "You're on the list.",
      onSubmit,
      waterColor = "#2563eb",
      strength = 1,
      rendering = "auto",
      onModeChange,
      className,
    }: RippleFormProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const formRef = useRef<HTMLFormElement>(null);
      const [height, setHeight] = useState<number | null>(null);
      const [state, setState] = useState<"idle" | "pending" | "done" | "error">("idle");
      const [error, setError] = useState("");
      const [animating, setAnimating] = useState(false);
      const ripples = useRef<Ripple[]>([]);
      const clock = useRef({ start: 0 });
      const gl = useRef<{ program: Program; mesh: Mesh; texture: ElementTexture } | null>(null);
      const [buttonRipple, setButtonRipple] = useState<{ x: number; y: number; key: number } | null>(null);
    
      const now = () => (performance.now() - clock.current.start) / 1000;
    
      const { native, canvasRef, canvasProps, invalidate } = useHtmlCanvas({
        context: "webgl2",
        mode: rendering,
        animating,
        setup: (ctx) => {
          clock.current.start = performance.now();
          const program = createProgram(ctx, VERT, FRAG);
          gl.current = { program, mesh: createGrid(ctx), texture: createElementTexture(ctx) };
          return () => {
            ctx.deleteProgram(program.program);
            ctx.deleteTexture(gl.current?.texture.texture ?? null);
            gl.current = null;
          };
        },
        render: ({ ctx, canvas, width, height: h, reason }) => {
          const form = formRef.current;
          const g = gl.current;
          if (!form || !g) return;
          if (reason === "paint" || reason === "resize" || reason === "request" || !g.texture.width) {
            uploadElementTexture(ctx, g.texture, form);
            syncElementGeometry(canvas, form, new DOMMatrix());
          }
          const t = now();
          ripples.current = ripples.current.filter((r) => t - r.t < RIPPLE_LIFE);
          const data = new Float32Array(MAX_RIPPLES * 4);
          ripples.current.slice(-MAX_RIPPLES).forEach((r, i) => data.set([r.x, r.y, r.t, r.amp], i * 4));
          const focused = document.activeElement;
          let focus = [0, 0, 0, 0];
          if (focused instanceof HTMLElement && form.contains(focused) && focused.matches("input:not([type=checkbox]), textarea")) {
            const a = focused.getBoundingClientRect();
            const b = canvas.getBoundingClientRect();
            focus = [a.left - b.left, a.top - b.top, a.width, a.height];
          }
    
          ctx.viewport(0, 0, canvas.width, canvas.height);
          ctx.clearColor(0, 0, 0, 0);
          ctx.clear(ctx.COLOR_BUFFER_BIT);
          ctx.enable(ctx.BLEND);
          ctx.blendFunc(ctx.ONE, ctx.ONE_MINUS_SRC_ALPHA);
          ctx.useProgram(g.program.program);
          const { u } = g.program;
          ctx.uniform2f(u("u_canvas"), width, h);
          ctx.uniform2f(u("u_size"), g.texture.width || width, g.texture.height || h);
          ctx.uniform1f(u("u_time"), t);
          ctx.uniform4fv(u("u_ripples"), data);
          ctx.uniform4f(u("u_focus"), focus[0], focus[1], focus[2], focus[3]);
          ctx.uniform3f(u("u_tint"), ...rgb(waterColor));
          ctx.uniform1f(u("u_strength"), Math.max(0, Math.min(2, strength)));
          ctx.activeTexture(ctx.TEXTURE0);
          ctx.bindTexture(ctx.TEXTURE_2D, g.texture.texture);
          ctx.uniform1i(u("u_tex"), 0);
          drawMesh(ctx, g.mesh);
    
          if (!ripples.current.length && animating) window.setTimeout(() => setAnimating(false), 0);
        },
      });
    
      useEffect(() => onModeChange?.(native), [native, onModeChange]);
    
      // The canvas is as tall as the form inside it.
      useEffect(() => {
        const form = formRef.current;
        if (!native || !form) return;
        const observer = new ResizeObserver(() => setHeight(form.offsetHeight));
        observer.observe(form);
        return () => observer.disconnect();
      }, [native]);
    
      const ripple = (x: number, y: number, amp = 1) => {
        if (!native) return;
        ripples.current.push({ x, y, t: now(), amp });
        if (ripples.current.length > MAX_RIPPLES) ripples.current.shift();
        setAnimating(true);
        invalidate();
      };
    
      const local = (clientX: number, clientY: number) => {
        const rect = formRef.current?.parentElement?.getBoundingClientRect();
        return rect ? { x: clientX - rect.left, y: clientY - rect.top } : { x: 0, y: 0 };
      };
    
      const onPointerDown = (event: ReactPointerEvent<HTMLFormElement>) => {
        const p = local(event.clientX, event.clientY);
        ripple(p.x, p.y);
      };
    
      const onKeyDown = (event: React.KeyboardEvent<HTMLFormElement>) => {
        const target = event.target;
        if (!(target instanceof HTMLInputElement) || target.type === "checkbox" || (event.key.length > 1 && event.key !== "Backspace")) return;
        // Measure after the key lands.
        requestAnimationFrame(() => {
          const box = target.getBoundingClientRect();
          const caret = caretOffset(target);
          const p = local(box.left + caret.x, box.top + caret.y);
          ripple(p.x, p.y, 0.55);
        });
      };
    
      const submit = async (event: FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        if (state === "pending") return;
        const data = new FormData(event.currentTarget);
        const values = { name: String(data.get("name") ?? ""), email: String(data.get("email") ?? ""), updates: data.get("updates") === "on" };
        setState("pending");
        setError("");
        try {
          await onSubmit?.(values);
          setState("done");
          const form = formRef.current;
          if (form) ripple(form.offsetWidth / 2, form.offsetHeight / 2, 2);
        } catch (reason) {
          setState("error");
          setError(reason instanceof Error ? reason.message : "Something went wrong. Try again.");
        }
      };
    
      const onButtonPointer = (event: ReactPointerEvent<HTMLButtonElement>) => {
        if (native || reduceMotion) return;
        const rect = event.currentTarget.getBoundingClientRect();
        setButtonRipple({ x: event.clientX - rect.left, y: event.clientY - rect.top, key: Date.now() });
      };
    
      const field = "h-10 w-full rounded-lg border border-border bg-background px-3 text-sm text-foreground outline-none transition-shadow placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30";
    
      const form = (
        <form
          ref={formRef}
          onSubmit={submit}
          onPointerDown={onPointerDown}
          onKeyDown={onKeyDown}
          aria-labelledby={`${id}-title`}
          className="block w-full rounded-2xl border border-border bg-background p-6 shadow-[0_1px_2px_rgb(0_0_0/0.04)]"
        >
          <AnimatePresence mode="wait" initial={false}>
            {state === "done" ? (
              <motion.div
                key="done"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                transition={{ duration: duration.base, ease: ease.out }}
                className="flex flex-col items-center py-8 text-center"
                role="status"
              >
                <span className="grid size-11 place-items-center rounded-full bg-success/12 text-[color-mix(in_oklab,var(--success)_80%,var(--foreground))]">
                  <Check className="size-5" aria-hidden="true" />
                </span>
                <h3 id={`${id}-title`} className="mt-3 text-base font-semibold text-foreground">
                  {successMessage}
                </h3>
                <p className="mt-1 text-sm text-muted-foreground">Watch your inbox for the next wave.</p>
                <button type="button" onClick={() => setState("idle")} className="mt-4 cursor-pointer rounded-md text-[13px] font-medium text-foreground underline underline-offset-4 outline-none focus-visible:ring-2 focus-visible:ring-ring">
                  Use another email
                </button>
              </motion.div>
            ) : (
              <motion.div key="fields" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: duration.quick }}>
                <h3 id={`${id}-title`} className="text-base font-semibold text-foreground">
                  {title}
                </h3>
                {description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
                <div className="mt-5 space-y-3">
                  <div>
                    <label htmlFor={`${id}-name`} className="mb-1.5 block text-[13px] font-medium text-foreground">
                      Name
                    </label>
                    <input id={`${id}-name`} name="name" autoComplete="name" placeholder="Ada Lovelace" className={field} />
                  </div>
                  <div>
                    <label htmlFor={`${id}-email`} className="mb-1.5 block text-[13px] font-medium text-foreground">
                      Email
                    </label>
                    <input id={`${id}-email`} name="email" type="email" required autoComplete="email" placeholder="ada@example.com" className={field} aria-invalid={state === "error" || undefined} aria-describedby={error ? `${id}-error` : undefined} />
                  </div>
                  <label className="flex cursor-pointer items-center gap-2.5 py-1 text-[13px] text-foreground">
                    <input type="checkbox" name="updates" defaultChecked className="size-4 cursor-pointer accent-[var(--primary)]" />
                    Send me occasional product updates
                  </label>
                </div>
                {error ? (
                  <p id={`${id}-error`} role="alert" className="mt-3 text-[13px] text-destructive-text">
                    {error}
                  </p>
                ) : null}
                <button
                  type="submit"
                  disabled={state === "pending"}
                  onPointerDown={onButtonPointer}
                  className="relative mt-5 h-10 w-full cursor-pointer overflow-hidden rounded-lg bg-foreground text-sm font-medium text-background outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-70"
                >
                  {buttonRipple ? (
                    <motion.span
                      key={buttonRipple.key}
                      aria-hidden="true"
                      className="pointer-events-none absolute size-8 rounded-full bg-background/35"
                      style={{ left: buttonRipple.x - 16, top: buttonRipple.y - 16 }}
                      initial={{ scale: 0, opacity: 1 }}
                      animate={{ scale: 14, opacity: 0 }}
                      transition={{ duration: 0.7, ease: "easeOut" }}
                    />
                  ) : null}
                  <span className="relative">{state === "pending" ? "Sending…" : submitLabel}</span>
                </button>
              </motion.div>
            )}
          </AnimatePresence>
        </form>
      );
    
      if (!native) {
        return <div className={cn("w-full max-w-sm", className)}>{form}</div>;
      }
    
      return (
        <div className={cn("relative w-full max-w-sm", className)} style={{ height: height ?? undefined }}>
          <canvas ref={canvasRef} {...canvasProps} className="absolute inset-0 block size-full">
            {form}
          </canvas>
        </div>
      );
    }

Usage

import { RippleForm } from "@/components/ui/ripple-form/ripple-form";

<RippleForm
  title="Join the waitlist"
  onSubmit={async ({ name, email, updates }) => {
    await joinWaitlist({ name, email, updates });
  }}
/>

API reference

PropTypeDefaultDescription
titlestring"Join the waitlist"Heading above the fields.
descriptionstring–Line under the heading.
submitLabelstring"Request access"Submit button label.
successMessagestring"You're on the list."Message shown after a successful submit.
onSubmit(values: RippleFormValues) => void | Promise<void>–Called with the values on submit. Return a promise to show a pending state; reject to show an error.
waterColorstring"#2563eb"Tint of the water's highlights.
strengthnumber1Ripple strength, 0–2.
renderingHtmlCanvasMode"auto""auto" uses HTML-in-Canvas when available, "fallback" never does.
onModeChange(native: boolean) => void–Called when the rendering mode is known.
classNamestring–Classes for the root.
  • Experimental: uses the HTML-in-Canvas origin trial through src/lib/html-canvas/, never the API directly. Works in every browser via the fallback.
  • The form texture is re-uploaded only when the browser reports a repaint (typing, focus, hover), and the frame loop runs only while ripples are fading.
  • Ripples are evaluated analytically in the fragment shader (up to 10 at once), so there is no simulation state to keep.

Accessibility

  • The form is real DOM inside the canvas, so labels, the tab order, autofill, validation and screen readers work as usual. Its geometry is synced to where it is drawn, so clicks and text selection land on the drawn fields.
  • Ripples fade out around the focused input, so the text being typed is never distorted. Errors are announced with role="alert" and success with role="status".
  • With reduced motion, or without the HTML-in-Canvas API, the same form renders as plain DOM; only the button keeps a small CSS ripple (none under reduced motion).