HTML in Canvasv0.3.0
Floating Screen
A live mini-app on a tilted 3D device screen.
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/floating-screen.jsonManual
Install the dependencies
npm install clsx 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 }; }Copy the source
components/ui/floating-screen/floating-screen.tsx"use client"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { motion, useMotionValue, useReducedMotion, useSpring } from "motion/react"; import { cn } from "@/lib/utils"; import { createGrid, createProgram, drawMesh, PROJECT_GLSL, ROUND_RECT_GLSL, setMatrix, type Mesh, type Program } from "@/lib/html-canvas/gl"; import { createElementTexture, uploadElementTexture, type ElementTexture } from "@/lib/html-canvas/texture-webgl"; import { perspective, syncElementGeometry } from "@/lib/html-canvas/sync-geometry"; import { useHtmlCanvas, type HtmlCanvasMode } from "@/lib/html-canvas/use-html-canvas"; export interface FloatingScreenProps { /** The live mini-app on the screen. It is sized to the screen and stays fully clickable. */ children: ReactNode; /** The device around the screen. @default "laptop" */ device?: "laptop" | "phone"; /** Most the device tilts toward the pointer, in degrees. @default 10 */ tilt?: number; /** Follow the pointer. @default true */ interactive?: boolean; /** "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 SIZES = { laptop: { w: 440, h: 276, bezel: 14, baseTilt: -6, stage: 420 }, phone: { w: 210, h: 430, bezel: 10, baseTilt: 0, stage: 500 }, } as const; const DISTANCE = 1100; const VERT = /* glsl */ `#version 300 es in vec2 a_pos; uniform vec2 u_size; uniform mat4 u_matrix; 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 SCREEN_FRAG = /* glsl */ `#version 300 es precision highp float; in vec2 v_uv; uniform sampler2D u_tex; uniform vec2 u_glare; out vec4 outColor; void main() { vec4 c = texture(u_tex, v_uv); // A glossy reflection band that slides with the tilt. float band = smoothstep(0.35, 0.0, abs(v_uv.x * 0.8 + v_uv.y * 0.6 - 0.55 - u_glare.x * 0.6)); c.rgb += band * 0.07 + (1.0 - v_uv.y) * 0.02; outColor = vec4(c.rgb, 1.0); }`; const BODY_FRAG = /* glsl */ `#version 300 es precision highp float; in vec2 v_uv; uniform vec2 u_size; uniform float u_kind; // 0 bezel (laptop), 1 deck, 2 phone body uniform float u_radius; out vec4 outColor; ${ROUND_RECT_GLSL} void main() { vec2 p = v_uv * u_size - u_size * 0.5; if (u_kind < 0.5 || u_kind > 1.5) { float d = sdRoundRect(p, u_size * 0.5, u_radius); float a = 1.0 - smoothstep(-1.0, 0.5, d); vec3 col = u_kind > 1.5 ? vec3(0.12, 0.12, 0.14) : vec3(0.09, 0.09, 0.1); col += (1.0 - smoothstep(-2.5, 0.0, d)) * 0.25 * (u_kind > 1.5 ? 1.0 : 0.6); outColor = vec4(col * a, a); return; } // Keyboard deck, softened toward the near edge (depth of field: focus is on the screen). float soft = 0.5 + v_uv.y * 3.5; float d = sdRoundRect(p, u_size * 0.5, u_radius); float a = 1.0 - smoothstep(-soft, soft, d); vec3 col = mix(vec3(0.84, 0.85, 0.88), vec3(0.76, 0.77, 0.8), v_uv.y); vec2 keys = vec2(u_size.x * 0.82, u_size.y * 0.42); vec2 kp = p - vec2(0.0, -u_size.y * 0.16); if (abs(kp.x) < keys.x * 0.5 && abs(kp.y) < keys.y * 0.5) { vec2 cell = vec2(keys.x / 14.0, keys.y / 5.0); vec2 q = mod(kp + keys * 0.5, cell) - cell * 0.5; float kd = sdRoundRect(q, cell * 0.5 - 1.6, 1.8); col = mix(col, vec3(0.22, 0.22, 0.25), 1.0 - smoothstep(-soft * 0.4, soft * 0.4, kd)); } vec2 tp = p - vec2(0.0, u_size.y * 0.3); float td = sdRoundRect(tp, vec2(u_size.x * 0.17, u_size.y * 0.13), 5.0); col = mix(col, vec3(0.79, 0.8, 0.83), (1.0 - smoothstep(-soft, soft, td)) * 0.9); outColor = vec4(col * a, a); }`; /** * A live DOM mini-app shown on a tilted 3D laptop or phone through HTML-in-Canvas, with a glossy screen * reflection and a softly defocused keyboard deck. The device leans toward the pointer and the app * stays fully clickable, because its geometry is synced through the same perspective matrix. Without * the API, the same device is built from CSS 3D transforms. */ export function FloatingScreen({ children, device = "laptop", tilt = 10, interactive = true, rendering = "auto", onModeChange, className }: FloatingScreenProps) { const reduceMotion = useReducedMotion(); const spec = SIZES[device]; const screenRef = useRef<HTMLDivElement>(null); const aim = useRef({ x: 0, y: 0, tx: 0, ty: 0 }); const [animating, setAnimating] = useState(false); const gl = useRef<{ screen: Program; body: Program; mesh: Mesh; texture: ElementTexture } | null>(null); // Fallback tilt follows the pointer through springs. const rx = useMotionValue<number>(spec.baseTilt); const ry = useMotionValue<number>(0); const srx = useSpring(rx, { stiffness: 120, damping: 18 }); const sry = useSpring(ry, { stiffness: 120, damping: 18 }); const { native, canvasRef, canvasProps } = useHtmlCanvas({ context: "webgl2", mode: rendering, animating, setup: (ctx) => { gl.current = { screen: createProgram(ctx, VERT, SCREEN_FRAG), body: createProgram(ctx, VERT, BODY_FRAG), mesh: createGrid(ctx), texture: createElementTexture(ctx) }; return () => { const g = gl.current; if (!g) return; ctx.deleteProgram(g.screen.program); ctx.deleteProgram(g.body.program); ctx.deleteTexture(g.texture.texture); gl.current = null; }; }, render: ({ ctx, canvas, width, height: h, reason, delta }) => { const g = gl.current; const screen = screenRef.current; if (!g || !screen) return; const a = aim.current; const k = reason === "frame" ? 1 - Math.exp(-delta * 7) : 0; a.x += (a.tx - a.x) * k; a.y += (a.ty - a.y) * k; const settled = Math.abs(a.tx - a.x) < 0.002 && Math.abs(a.ty - a.y) < 0.002; if (settled && animating) window.setTimeout(() => setAnimating(false), 0); const { w, h: sh, bezel, baseTilt } = spec; const lift = device === "laptop" ? -40 : 0; const plane = new DOMMatrix() .translate(width / 2, h / 2 + lift) .multiply(perspective(DISTANCE)) .rotate(baseTilt - a.y * tilt * 0.8, a.x * tilt, 0) .translate(-w / 2, -sh / 2); if (reason !== "frame" || !g.texture.width) uploadElementTexture(ctx, g.texture, screen); syncElementGeometry(canvas, screen, plane); ctx.viewport(0, 0, canvas.width, canvas.height); ctx.clearColor(0, 0, 0, 0); ctx.clear(ctx.COLOR_BUFFER_BIT); ctx.enable(ctx.BLEND); ctx.blendFunc(ctx.ONE, ctx.ONE_MINUS_SRC_ALPHA); ctx.activeTexture(ctx.TEXTURE0); ctx.useProgram(g.body.program); ctx.uniform2f(g.body.u("u_canvas"), width, h); if (device === "laptop") { // Deck: hinged at the screen's bottom edge, folded toward the viewer. const deckW = w + bezel * 2 + 36; const deckH = 190; const deck = plane.translate(-(bezel + 18), sh + bezel).rotate(78, 0, 0); setMatrix(ctx, g.body.u("u_matrix"), deck); ctx.uniform2f(g.body.u("u_size"), deckW, deckH); ctx.uniform1f(g.body.u("u_kind"), 1); ctx.uniform1f(g.body.u("u_radius"), 14); drawMesh(ctx, g.mesh); } const frame = plane.translate(-bezel, -bezel); setMatrix(ctx, g.body.u("u_matrix"), frame); ctx.uniform2f(g.body.u("u_size"), w + bezel * 2, sh + bezel * 2 + (device === "laptop" ? 6 : 0)); ctx.uniform1f(g.body.u("u_kind"), device === "laptop" ? 0 : 2); ctx.uniform1f(g.body.u("u_radius"), device === "laptop" ? 14 : 34); drawMesh(ctx, g.mesh); ctx.useProgram(g.screen.program); ctx.uniform2f(g.screen.u("u_canvas"), width, h); setMatrix(ctx, g.screen.u("u_matrix"), plane); ctx.uniform2f(g.screen.u("u_size"), w, sh); ctx.uniform2f(g.screen.u("u_glare"), a.x, a.y); ctx.bindTexture(ctx.TEXTURE_2D, g.texture.texture); ctx.uniform1i(g.screen.u("u_tex"), 0); drawMesh(ctx, g.mesh); }, }); useEffect(() => onModeChange?.(native), [native, onModeChange]); const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => { if (!interactive || event.pointerType === "touch") return; const rect = event.currentTarget.getBoundingClientRect(); const x = ((event.clientX - rect.left) / rect.width) * 2 - 1; const y = ((event.clientY - rect.top) / rect.height) * 2 - 1; aim.current.tx = Math.max(-1, Math.min(1, x)); aim.current.ty = Math.max(-1, Math.min(1, y)); if (native) setAnimating(true); else if (!reduceMotion) { ry.set(aim.current.tx * tilt); rx.set(spec.baseTilt - aim.current.ty * tilt * 0.8); } }; const onPointerLeave = () => { aim.current.tx = 0; aim.current.ty = 0; if (native) setAnimating(true); else { ry.set(0); rx.set(spec.baseTilt); } }; const screen = ( <div ref={screenRef} className="overflow-hidden bg-background" style={{ width: spec.w, height: spec.h, borderRadius: device === "phone" ? 26 : 4 }}> {children} </div> ); return ( <div className={cn("relative w-full max-w-2xl overflow-hidden", className)} style={{ height: spec.stage }} onPointerMove={onPointerMove} onPointerLeave={onPointerLeave} > {native ? ( <canvas ref={canvasRef} {...canvasProps} className="absolute inset-0 block size-full"> {screen} </canvas> ) : ( <div className="absolute inset-0 grid place-items-center [perspective:1100px]"> <motion.div style={{ rotateX: srx, rotateY: sry, transformStyle: "preserve-3d", y: device === "laptop" ? -40 : 0 }} className="relative"> <div className={cn("bg-[#17171a] shadow-[inset_0_0_0_1.5px_rgb(255_255_255/0.12)]", device === "phone" ? "rounded-[34px] p-2.5" : "rounded-[14px] p-3.5 pb-5")}>{screen}</div> {device === "laptop" ? ( <div aria-hidden="true" className="absolute top-full left-1/2 h-[190px] origin-top rounded-[14px] bg-[linear-gradient(#d6d7db,#c2c4c9)]" style={{ width: spec.w + spec.bezel * 2 + 36, transform: "translateX(-50%) rotateX(78deg)" }} /> ) : null} </motion.div> </div> )} </div> ); }
Usage
import { FloatingScreen } from "@/components/ui/floating-screen/floating-screen";
<FloatingScreen device="laptop">
<MiniDashboard />
</FloatingScreen>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
children* | ReactNode | – | The live mini-app on the screen. It is sized to the screen and stays fully clickable. |
device | "laptop" | "phone" | "laptop" | The device around the screen. |
tilt | number | 10 | Most the device tilts toward the pointer, in degrees. |
interactive | boolean | true | Follow the pointer. |
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.
- The screen texture is uploaded only when the app repaints; tilting only redraws, and the frame loop runs only while the tilt eases toward the pointer.
- The laptop screen is 440 × 276 px and the phone 210 × 430 px; build the app for that size (it is clipped to it).
Accessibility
- The app on the screen is live DOM with its own semantics and tab order. Its geometry is synced through the same perspective matrix it is drawn with, so clicks, focus rings and text selection land on the tilted screen.
- Tilting follows a mouse or pen only, never touch, so phones scroll normally. The device body is decorative.
- With reduced motion the device does not tilt; without the HTML-in-Canvas API the same device is built from CSS 3D transforms, which keep clicks working natively.