Skip to content

Disintegrate Delete

Deleted rows crumble into dust, with undo.

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/disintegrate-delete.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. Copy the source

    components/ui/disintegrate-delete/disintegrate-delete.tsx
    "use client";
    
    import { useEffect, useId, useRef, useState, type ReactNode } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { Trash2, Undo2 } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration, ease } from "@/lib/motion";
    import { createGrid, createProgram, drawMesh, PROJECT_GLSL, setMatrix, 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 DisintegrateItem {
      id: string;
      title: string;
      subtitle?: string;
      /** Leading visual, e.g. an icon or avatar. */
      icon?: ReactNode;
    }
    
    export interface DisintegrateDeleteProps {
      /** Starting rows. The list manages deletes and undos itself. */
      items: DisintegrateItem[];
      /** Called when a row is deleted. */
      onDelete?: (item: DisintegrateItem) => void;
      /** Called when a deleted row is restored with Undo. */
      onRestore?: (item: DisintegrateItem) => void;
      /** Milliseconds the Undo toast stays. @default 5000 */
      undoTimeout?: number;
      /** Seconds the dust takes. @default 1.1 */
      dustDuration?: number;
      /** Accessible name for the list. @default "Items" */
      label?: string;
      /** Message when every row is gone. @default "Nothing here." */
      emptyMessage?: string;
      /** "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 CELL = 3;
    
    const QUAD_VERT = /* glsl */ `#version 300 es
    in vec2 a_pos;
    uniform mat4 u_matrix;
    uniform vec2 u_size;
    out vec2 v_uv;
    ${PROJECT_GLSL}
    void main() {
      v_uv = a_pos;
      gl_Position = projectCss(u_matrix * vec4(a_pos * u_size, 0.0, 1.0));
    }`;
    
    const QUAD_FRAG = /* glsl */ `#version 300 es
    precision highp float;
    in vec2 v_uv;
    uniform sampler2D u_tex;
    uniform float u_alpha;
    out vec4 outColor;
    void main() { outColor = texture(u_tex, v_uv) * u_alpha; }`;
    
    const DUST_VERT = /* glsl */ `#version 300 es
    in vec2 a_pos;
    uniform vec2 u_offset;
    uniform vec2 u_grid;
    uniform float u_t;
    out vec2 v_uv;
    out float v_alpha;
    ${PROJECT_GLSL}
    float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
    void main() {
      float cols = u_grid.x;
      vec2 cell = vec2(mod(float(gl_InstanceID), cols), floor(float(gl_InstanceID) / cols));
      float r = hash(cell);
      float r2 = hash(cell + 17.0);
      // A sweep from left to right with some scatter, like dust blown off the row.
      float delay = (cell.x / cols) * 0.5 + r * 0.18;
      float local = clamp((u_t - delay) / 0.42, 0.0, 1.0);
      float e = local * local;
      vec2 dir = normalize(vec2(0.55 + r * 0.9, -1.0 - r2 * 0.7));
      vec2 drift = dir * e * (50.0 + r2 * 110.0) + vec2(sin(r * 30.0 + e * 7.0) * 9.0 * e, 0.0);
      float size = ${CELL.toFixed(1)} * (1.0 - e * 0.5) + 0.35;
      vec2 p = u_offset + (cell + 0.5) * ${CELL.toFixed(1)} + drift + (a_pos - 0.5) * size;
      v_uv = (cell + a_pos) / u_grid;
      v_alpha = 1.0 - smoothstep(0.35, 1.0, local);
      gl_Position = projectCss(vec4(p, 0.0, 1.0));
    }`;
    
    const DUST_FRAG = /* glsl */ `#version 300 es
    precision highp float;
    in vec2 v_uv;
    in float v_alpha;
    uniform sampler2D u_tex;
    uniform vec2 u_texScale;
    out vec4 outColor;
    void main() { outColor = texture(u_tex, min(v_uv * u_texScale, vec2(0.999))) * v_alpha; }`;
    
    type Dust = { id: string; item: DisintegrateItem; index: number; texture: ElementTexture; top: number; start: number; reverse: boolean; finished?: boolean };
    
    /** Milliseconds from a monotonic clock (read in callbacks, never during render). */
    const nowMs = () => performance.now();
    
    /** Room above the list for dust to drift into, in px. */
    const HEADROOM = 88;
    
    /**
     * A list where deleting a row (live DOM with real buttons) makes it disintegrate through
     * HTML-in-Canvas: the row's pixels break into dust that drifts up and fades, then the gap closes. An
     * Undo toast reassembles the row in reverse. Without the API, rows fade and collapse.
     */
    export function DisintegrateDelete({
      items: initial,
      onDelete,
      onRestore,
      undoTimeout = 5000,
      dustDuration = 1.1,
      label = "Items",
      emptyMessage = "Nothing here.",
      rendering = "auto",
      onModeChange,
      className,
    }: DisintegrateDeleteProps) {
      const id = useId();
      const reduceMotion = useReducedMotion();
      const [items, setItems] = useState(initial);
      const [toast, setToast] = useState<{ item: DisintegrateItem; index: number; key: number } | null>(null);
      const [announce, setAnnounce] = useState("");
      const [animating, setAnimating] = useState(false);
      const rows = useRef(new Map<string, HTMLDivElement>());
      const textures = useRef(new Map<string, ElementTexture>());
      const drawnY = useRef(new Map<string, number>());
      const dust = useRef<Dust[]>([]);
      const progs = useRef<{ quad: Program; dust: Program; mesh: Mesh } | null>(null);
      const glRef = useRef<WebGL2RenderingContext | null>(null);
      const [height, setHeight] = useState<number | null>(null);
      const focusAfter = useRef<string | null>(null);
      // Canvas children all lay out at the canvas origin, so the list stacks its rows itself.
      const order = useRef<string[]>(initial.map((i) => i.id));
      useEffect(() => {
        order.current = items.map((i) => i.id);
      }, [items]);
      const stack = () => {
        const tops = new Map<string, number>();
        let y = HEADROOM;
        order.current.forEach((rowId) => {
          const row = rows.current.get(rowId);
          if (!row) return;
          tops.set(rowId, y);
          y += row.offsetHeight;
        });
        return { tops, bottom: y };
      };
    
      const { native, canvasRef, canvasProps, invalidate } = useHtmlCanvas({
        context: "webgl2",
        mode: rendering,
        animating,
        setup: (ctx) => {
          glRef.current = ctx;
          progs.current = { quad: createProgram(ctx, QUAD_VERT, QUAD_FRAG), dust: createProgram(ctx, DUST_VERT, DUST_FRAG), mesh: createGrid(ctx) };
          return () => {
            textures.current.forEach((t) => ctx.deleteTexture(t.texture));
            textures.current.clear();
            dust.current.forEach((d) => ctx.deleteTexture(d.texture.texture));
            dust.current = [];
            if (progs.current) {
              ctx.deleteProgram(progs.current.quad.program);
              ctx.deleteProgram(progs.current.dust.program);
            }
            progs.current = null;
            glRef.current = null;
          };
        },
        render: ({ ctx, canvas, width, height: h, reason, changed, delta }) => {
          const p = progs.current;
          if (!p) return;
          const now = nowMs();
          let moving = false;
    
          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);
    
          // Rows: upload what repainted, ease each row toward its layout position, sync its geometry.
          ctx.useProgram(p.quad.program);
          ctx.uniform2f(p.quad.u("u_canvas"), width, h);
          const reforming = new Set(dust.current.filter((d) => d.reverse).map((d) => d.id));
          const { tops } = stack();
          rows.current.forEach((row, rowId) => {
            let tex = textures.current.get(rowId);
            if (!tex) {
              tex = createElementTexture(ctx);
              textures.current.set(rowId, tex);
            }
            const repainted = reason === "resize" || reason === "request" || !tex.width || changed.some((el) => el === row || row.contains(el));
            if (repainted) uploadElementTexture(ctx, tex, row);
            const target = tops.get(rowId) ?? HEADROOM;
            const prev = drawnY.current.get(rowId) ?? target;
            const y = Math.abs(target - prev) < 0.4 ? target : prev + (target - prev) * (1 - Math.exp(-delta * 14));
            if (y !== target) moving = true;
            drawnY.current.set(rowId, y);
            const m = new DOMMatrix().translate(0, y);
            // Sync hit-testing when the row repaints or comes to rest, not on every frame of the slide:
            // moving the element triggers a repaint (and a texture upload) of its own.
            const settled = y === target && prev !== target;
            if (repainted || settled) syncElementGeometry(canvas, row, m);
            if (reforming.has(rowId)) return;
            setMatrix(ctx, p.quad.u("u_matrix"), m);
            ctx.uniform2f(p.quad.u("u_size"), tex.width || row.offsetWidth, tex.height || row.offsetHeight);
            ctx.uniform1f(p.quad.u("u_alpha"), 1);
            ctx.activeTexture(ctx.TEXTURE0);
            ctx.bindTexture(ctx.TEXTURE_2D, tex.texture);
            ctx.uniform1i(p.quad.u("u_tex"), 0);
            drawMesh(ctx, p.mesh);
          });
    
          // Dust.
          ctx.useProgram(p.dust.program);
          ctx.uniform2f(p.dust.u("u_canvas"), width, h);
          const done: Dust[] = [];
          dust.current.forEach((d) => {
            if (d.finished) return;
            const t = Math.min(1, (now - d.start) / 1000 / dustDuration);
            if (t >= 1) done.push(d);
            else moving = true;
            const top = d.reverse ? (drawnY.current.get(d.id) ?? d.top) : d.top;
            const cols = Math.ceil(d.texture.width / CELL);
            const rowsN = Math.ceil(d.texture.height / CELL);
            ctx.uniform2f(p.dust.u("u_offset"), 0, top);
            ctx.uniform2f(p.dust.u("u_grid"), cols, rowsN);
            ctx.uniform2f(p.dust.u("u_texScale"), (cols * CELL) / d.texture.width, (rowsN * CELL) / d.texture.height);
            ctx.uniform1f(p.dust.u("u_t"), d.reverse ? 1 - t : t);
            ctx.activeTexture(ctx.TEXTURE0);
            ctx.bindTexture(ctx.TEXTURE_2D, d.texture.texture);
            ctx.uniform1i(p.dust.u("u_tex"), 0);
            drawMesh(ctx, p.mesh, cols * rowsN);
          });
          if (done.length) {
            // A finished reassembly shows the live row again; a finished deletion keeps its texture for Undo.
            dust.current = dust.current.filter((d) => !(done.includes(d) && d.reverse));
            dust.current.forEach((d) => {
              if (done.includes(d)) d.finished = true;
            });
            moving = true;
          }
          if (!moving && animating) window.setTimeout(() => setAnimating(false), 0);
        },
      });
    
      useEffect(() => onModeChange?.(native), [native, onModeChange]);
    
      // The canvas is as tall as its rows.
      useEffect(() => {
        if (!native) return;
        const measure = () => setHeight(stack().bottom - HEADROOM);
        const observer = new ResizeObserver(measure);
        rows.current.forEach((row) => observer.observe(row));
        measure();
        return () => observer.disconnect();
      }, [native, items]);
    
      // The toast dismisses itself; once Undo is gone, finished deletions free their textures.
      useEffect(() => {
        if (!toast) {
          const ctx = glRef.current;
          dust.current = dust.current.filter((d) => {
            if (!d.finished || d.reverse) return true;
            ctx?.deleteTexture(d.texture.texture);
            return false;
          });
          return;
        }
        const timer = window.setTimeout(() => setToast(null), undoTimeout);
        return () => window.clearTimeout(timer);
      }, [toast, undoTimeout]);
    
      // Move focus to the row that took a deleted row's place, or to the restored row.
      useEffect(() => {
        const target = focusAfter.current;
        if (!target) return;
        focusAfter.current = null;
        const row = rows.current.get(target) ?? document.getElementById(`${id}-${target}`);
        row?.querySelector<HTMLElement>("button")?.focus();
      }, [items, id]);
    
      const remove = (item: DisintegrateItem) => {
        const index = items.findIndex((i) => i.id === item.id);
        if (index < 0) return;
        const ctx = glRef.current;
        const tex = textures.current.get(item.id);
        if (native && ctx && tex && tex.width) {
          textures.current.delete(item.id);
          dust.current.forEach((d) => d.id !== item.id || ctx.deleteTexture(d.texture.texture));
          dust.current = [...dust.current.filter((d) => d.id !== item.id), { id: item.id, item, index, texture: tex, top: drawnY.current.get(item.id) ?? 0, start: nowMs(), reverse: false }];
          drawnY.current.delete(item.id);
          setAnimating(true);
        }
        const next = items[index + 1] ?? items[index - 1];
        focusAfter.current = next?.id ?? null;
        setItems((list) => list.filter((i) => i.id !== item.id));
        setToast({ item, index, key: nowMs() });
        setAnnounce(`Deleted ${item.title}. Undo is available.`);
        onDelete?.(item);
        invalidate();
      };
    
      const undo = () => {
        if (!toast) return;
        const { item, index } = toast;
        setItems((list) => {
          const copy = list.filter((i) => i.id !== item.id);
          copy.splice(Math.min(index, copy.length), 0, item);
          return copy;
        });
        const old = dust.current.find((d) => d.id === item.id && !d.reverse);
        if (native && old) {
          // Reassemble: play the same dust backwards over the row's new slot, then show the live row.
          dust.current = [...dust.current.filter((d) => d !== old), { ...old, start: nowMs(), reverse: true }];
          textures.current.set(item.id, old.texture);
          setAnimating(true);
        }
        focusAfter.current = item.id;
        setToast(null);
        setAnnounce(`Restored ${item.title}.`);
        onRestore?.(item);
        invalidate();
      };
    
      const rowClass = "flex w-full items-center gap-3 border-b border-border bg-background px-3 py-3 last:border-b-0";
      const rowContent = (item: DisintegrateItem) => (
        <>
          {item.icon ? <span className="grid size-9 shrink-0 place-items-center rounded-full bg-surface-2 text-foreground">{item.icon}</span> : null}
          <div className="min-w-0 flex-1">
            <p className="truncate text-sm font-medium text-foreground">{item.title}</p>
            {item.subtitle ? <p className="truncate text-[12px] text-muted-foreground">{item.subtitle}</p> : null}
          </div>
          <button
            type="button"
            onClick={() => remove(item)}
            aria-label={`Delete ${item.title}`}
            className="relative after:absolute after:-inset-0.5 grid size-9 shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-2 focus-visible:ring-ring"
          >
            <Trash2 className="size-4" aria-hidden="true" />
          </button>
        </>
      );
    
      const toastEl = (
        <div aria-live="polite" className="mt-3 flex min-h-11 justify-center">
          <span className="sr-only">{announce}</span>
          <AnimatePresence>
            {toast ? (
              <motion.div
                key={toast.key}
                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.98 }}
                animate={{ opacity: 1, y: 0, scale: 1 }}
                exit={{ opacity: 0, transition: { duration: duration.quick } }}
                transition={{ duration: duration.base, ease: ease.out }}
                className="flex items-center gap-3 rounded-full bg-foreground py-1.5 pr-1.5 pl-4 text-[13px] text-background shadow-lg"
              >
                <span className="max-w-[14rem] truncate">Deleted {toast.item.title}</span>
                <button type="button" onClick={undo} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full bg-background/15 px-3 font-medium outline-none hover:bg-background/25 focus-visible:ring-2 focus-visible:ring-background">
                  <Undo2 className="size-3.5" aria-hidden="true" />
                  Undo
                </button>
              </motion.div>
            ) : null}
          </AnimatePresence>
        </div>
      );
    
      const empty = items.length === 0 ? <p className="px-3 py-10 text-center text-sm text-muted-foreground">{emptyMessage}</p> : null;
    
      if (!native) {
        return (
          <div className={cn("w-full max-w-md", className)}>
            <div className="overflow-hidden rounded-2xl border border-border bg-background">
              <ul aria-label={label}>
                <AnimatePresence initial={false}>
                  {items.map((item) => (
                    <motion.li
                      key={item.id}
                      id={`${id}-${item.id}`}
                      initial={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0 }}
                      animate={{ opacity: 1, height: "auto" }}
                      exit={reduceMotion ? { opacity: 0 } : { opacity: 0, height: 0 }}
                      transition={{ duration: reduceMotion ? duration.quick : duration.base, ease: ease.out }}
                      className="overflow-hidden"
                    >
                      <div className={rowClass}>{rowContent(item)}</div>
                    </motion.li>
                  ))}
                </AnimatePresence>
              </ul>
              {empty}
            </div>
            {toastEl}
          </div>
        );
      }
    
      return (
        <div className={cn("w-full max-w-md", className)}>
          <div className="relative overflow-visible rounded-2xl border border-border bg-background">
            <div className="relative" style={{ height: height ?? undefined }}>
              {/* The canvas reaches above the list so dust can drift up; only the rows take pointer events. */}
              <canvas ref={canvasRef} {...canvasProps} role="list" aria-label={label} className="pointer-events-none absolute inset-x-0 bottom-0 block w-full" style={{ top: -HEADROOM, height: `calc(100% + ${HEADROOM}px)` }}>
                {items.map((item, index) => (
                  <div
                    key={item.id}
                    id={`${id}-${item.id}`}
                    role="listitem"
                    ref={(el) => {
                      if (el) rows.current.set(item.id, el);
                      else rows.current.delete(item.id);
                    }}
                    className={cn(rowClass, "pointer-events-auto", index === 0 && "rounded-t-2xl", index === items.length - 1 && "rounded-b-2xl")}
                  >
                    {rowContent(item)}
                  </div>
                ))}
              </canvas>
            </div>
            {empty}
          </div>
          {toastEl}
        </div>
      );
    }

Usage

import { DisintegrateDelete } from "@/components/ui/disintegrate-delete/disintegrate-delete";

<DisintegrateDelete
  items={[{ id: "brief", title: "Launch brief.pdf", subtitle: "2.4 MB" }]}
  onDelete={(item) => trash(item.id)}
  onRestore={(item) => restore(item.id)}
/>

API reference

PropTypeDefaultDescription
items*DisintegrateItem[]–Starting rows. The list manages deletes and undos itself.
onDelete(item: DisintegrateItem) => void–Called when a row is deleted.
onRestore(item: DisintegrateItem) => void–Called when a deleted row is restored with Undo.
undoTimeoutnumber5000Milliseconds the Undo toast stays.
dustDurationnumber1.1Seconds the dust takes.
labelstring"Items"Accessible name for the list.
emptyMessagestring"Nothing here."Message when every row is gone.
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/. Works in every browser via the fallback.
  • Each row is drawn from its own texture, re-uploaded only when that row repaints. Dust is one instanced draw of 3 px cells sampling the deleted row's last texture, which is kept until the Undo toast goes away.
  • The canvas extends above the list so dust can drift upward; only the rows take pointer events.

Accessibility

  • Rows are a labelled list of live DOM rows, each with a named Delete button. Their geometry follows the drawn rows while the gap closes, so the next row's button stays clickable.
  • Deleting moves focus to the next row (or the previous one), and announces "Deleted … Undo is available". Undo restores the row in place, moves focus back to it and announces it.
  • With reduced motion, or without the HTML-in-Canvas API, rows fade and collapse instead (a plain fade under reduced motion).