HTML in Canvasv0.3.0
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.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand 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.tsimport 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.tsimport { 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.tsimport { 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.tsimport 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 } }, }; }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
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
undoTimeout | number | 5000 | Milliseconds the Undo toast stays. |
dustDuration | number | 1.1 | Seconds the dust takes. |
label | string | "Items" | Accessible name for the list. |
emptyMessage | string | "Nothing here." | Message when every row is gone. |
rendering | HtmlCanvasMode | "auto" | "auto" uses HTML-in-Canvas when available, "fallback" never does. |
onModeChange | (native: boolean) => void | – | Called when the rendering mode is known. |
className | string | – | 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).