HTML-in-Canvas: dibujar HTML vivo dentro de un canvas

Si alguna vez tuviste que poner texto adentro de un <canvas>, ya sabés que es de las cosas más ingratas de la plataforma web. fillText() dibuja texto crudo, una línea: nada de word-wrap, justificado, elipsis ni similares. ¿Querés un botón con un borde redondeado, un input, una tabla con scroll? Lo armás a mano, pixel por pixel, reimplementando layout que el navegador ya sabe hacer perfecto tres centímetros más allá, fuera del canvas.
Y eso es solo lo visible. Lo que dibujás en un canvas es una caja negra: no está en el accessibility tree, no lo lee un screen reader, no lo resalta el find-in-page, no se puede seleccionar ni copiar, no lo indexa un crawler. Para una app de gráficos chiquita no importa. Para un Figma, un Miro o un Google Docs que renderizan su workspace sobre canvas por performance, es un problema serio que se resuelve con muchísimo código de accesibilidad paralelo y mirror DOMs invisibles.
La disyuntiva histórica fue siempre esa: DOM o canvas. DOM te da semántica, accesibilidad, formularios, i18n y todas las features del navegador, pero te ata al modelo de cajas. Canvas te da control de bajo nivel y performance gráfica, pero abajo no hay nada que un humano (o una assistive technology) entienda.
Los hacks de siempre
Antes de meternos en la API nueva, vale recordar cómo se venía resolviendo esto, porque explica de dónde sale la propuesta.
El truco más conocido es el de <foreignObject> dentro de un SVG. Serializás tu HTML, lo metés en un <svg><foreignObject>, convertís ese SVG en una data: URL, lo cargás en una Image y recién ahí lo pasás a drawImage():
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200">
<foreignObject width="100%" height="100%">
<div xmlns="http://www.w3.org/1999/xhtml">Hola desde HTML</div>
</foreignObject>
</svg>`;
const img = new Image();
img.onload = () => ctx.drawImage(img, 0, 0);
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
Funciona a medias y tiene asteriscos de todos los colores: no podés referenciar CSS externo ni fuentes sin que estén inline, el contenido queda totalmente estático (es una imagen), no hay interactividad y no hay accesibilidad. Y según el contenido y el navegador, el canvas puede quedar "tainted" y bloquearte getImageData() o toDataURL() —lo que históricamente disparaba eso era cargar recursos cross-origin, no el foreignObject en sí, aunque la combinación dio dolores de cabeza durante años—. La otra opción, librerías tipo html2canvas, directamente reimplementan un motor de layout en JavaScript: lento, incompleto y siempre un paso atrás del CSS real.
En todos los casos terminás con un pixmap muerto. Lo que faltaba era una forma de dibujar HTML que siga siendo HTML: vivo, accesible e interactivo.
La propuesta: HTML-in-Canvas
HTML-in-Canvas es una propuesta incubándose en el WICG (Web Incubator Community Group) que apunta exactamente a eso. La idea es tomar elementos HTML reales —que son hijos del <canvas>—, dejar que el motor los maquete con CSS como a cualquier otro nodo, y después dibujarlos dentro del canvas (2D, WebGL o WebGPU) manteniendo un árbol DOM accesible que coincide con lo que se ve en pantalla.
El mecanismo tiene tres piezas:
- Un atributo
layoutsubtreeque se agrega al<canvas>para optar al comportamiento. - Métodos para dibujar elementos según el contexto:
drawElementImage()en 2D,texElementImage2D()en WebGL ycopyElementImageToTexture()en WebGPU. - Un evento
paintque coordina cuándo redibujar, más un mecanismo para sincronizar eltransformdel elemento DOM con la posición donde lo dibujaste (así el foco y el hit-testing caen donde el usuario realmente ve el elemento).
Estándar vs. implementación: como en otras APIs experimentales, hay un desfase entre lo que propone el explainer del WICG y lo que Chrome shippea hoy. El README del WICG describe la superficie completa —2D, WebGL, WebGPU y hasta Workers via
OffscreenCanvas— con su IDL. Lo que Chrome tiene detrás del flag en Canary es principalmente el camino 2D (layoutsubtree+drawElementImage+ eventopaint). El resto del IDL es propuesta a estandarizar, no necesariamente código ya enviado.
El ejemplo mínimo en 2D
Arrancamos con un formulario de verdad —un <input> con su <label>— dibujado adentro del canvas:
<canvas id="canvas" style="width: 200px; height: 200px;" layoutsubtree>
<div id="form_element">
<label for="name">Name:</label> <input id="name" type="text">
</div>
</canvas>
Al poner layoutsubtree, los hijos del canvas siguen participando del layout y del hit-testing, pero se vuelven invisibles hasta que los dibujás explícitamente. Cada hijo directo se convierte en stacking context y containing block con paint containment. O sea: el navegador ya sabe dónde está y qué tamaño tiene el <input>, pero no lo pinta solo.
Para dibujarlo escuchás el evento paint y llamás a drawElementImage():
const ctx = document.getElementById('canvas').getContext('2d');
const form_element = document.getElementById('form_element');
const canvas = document.getElementById('canvas');
canvas.onpaint = () => {
ctx.reset();
// Dibuja el form element en x:0, y:0
let transform = ctx.drawElementImage(form_element, 0, 0);
// Sincroniza la ubicación del DOM con la dibujada
form_element.style.transform = transform.toString();
};
Acá está la parte clave y la que parece magia negra al principio. drawElementImage(element, dx, dy) hace dos cosas: dibuja el elemento en el canvas en la posición que le pasás, y devuelve un DOMMatrix con el transform que tenés que aplicarle al elemento vía element.style.transform.
¿Por qué ese baile? Porque el píxel que ves está en el canvas, pero el elemento DOM real —el que recibe el click, el foco y el hit-testing— sigue viviendo en su posición original de layout. Si no movés el DOM, el usuario ve el input en un lado y al hacer click el foco cae en otro. Aplicando el transform que te devuelve el método, alineás la posición lógica del elemento con la posición donde lo pintaste. Ahí el foco, los eventos y el accessibility tree caen exactamente donde el usuario ve las cosas.
Un detalle importante: el método ignora los CSS transforms propios del elemento fuente al momento de dibujar (usa la matriz de transformación del canvas), y el contenido que desborda se clippea al border box.
El tema del blur
Si dibujás así nomás, en pantallas con device pixel ratio alto el contenido te va a salir borroso, porque la grilla interna del canvas no coincide con los píxeles físicos. La recomendación es usar un ResizeObserver con device-pixel-content-box para setear canvas.width/height al device pixel content box:
const observer = new ResizeObserver(([entry]) => {
const dpc = entry.devicePixelContentBoxSize;
canvas.width = dpc ? dpc[0].inlineSize : Math.round(entry.contentRect.width * window.devicePixelRatio);
canvas.height = dpc ? dpc[0].blockSize : Math.round(entry.contentRect.height * window.devicePixelRatio);
});
const supportsDevicePixelContentBox =
typeof ResizeObserverEntry !== 'undefined' &&
'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox ? { box: 'device-pixel-content-box' } : {};
observer.observe(canvas, options);
El fallback a devicePixelRatio es por si el browser no soporta device-pixel-content-box, que es bastante reciente.
El evento paint y requestPaint()
El evento paint se dispara cuando cambia el render de algún hijo del canvas, justo después del paso de los IntersectionObserver. En el handler te llega un PaintEvent con changedElements (un FrozenArray<Element>), así sabés qué se modificó.
El diseño del timing no fue trivial. En el explainer se evaluaron tres opciones —disparar en el timing del resize observer, disparar después del Paint con loop, o disparar una sola vez después del Paint— y se eligió la última: paint corre una vez por frame, parecido al propio paso de Paint del navegador. La consecuencia práctica es que las invalidaciones de DOM que hagas durante paint aplican al frame siguiente; el contenido que dibujás al canvas es la excepción intencional.
¿Y si querés forzar un repintado aunque no haya cambiado nada (por ejemplo, una animación manejada manualmente)? Para eso está requestPaint(), análogo a requestAnimationFrame(): pide que el evento paint se dispare una vez más.
No te olvides de "despintar": Si dejás de dibujar un elemento, es crítico sacarlo del canvas; si no, el browser lo va a seguir presentando como parte de la página a los sistemas de accesibilidad, aunque visualmente ya no esté. El elemento sigue "vivo" en el árbol mientras esté ahí, así que limpialo cuando una vista se desmonta.
Llevarlo a WebGL y WebGPU
Acá entramos en la parte más experimental de la propuesta (recordá: el camino 2D es lo principal en Chrome hoy; 3D y Workers son superficie propuesta).
La gracia es poder usar el HTML como textura de una escena 3D. En WebGL existe texElementImage2D(), el análogo a texImage2D pero que toma un elemento DOM en lugar de una imagen o un ArrayBuffer. La firma propuesta es texElementImage2D(target, internalformat, element):
canvas.onpaint = () => {
if (gl.texElementImage2D) {
gl.texElementImage2D(gl.TEXTURE_2D, gl.RGBA, form_element);
}
};
En WebGPU el equivalente es copyElementImageToTexture() sobre la queue (valueElement, targetTexture y root.device son placeholders de tu propio setup de WebGPU):
canvas.onpaint = () => {
root.device.queue.copyElementImageToTexture(
valueElement,
{ texture: targetTexture }
);
};
El problema de la sincronización del transform vuelve, pero más difícil: en 3D no hay un drawElementImage que te devuelva la matriz directamente, porque la posición final del HTML depende de tu matriz Model-View-Projection. Para eso existe el helper getElementTransform(element, drawTransform), al que le pasás una matriz de transformación general y te devuelve el CSS transform que corresponde. La cuenta para armar esa matriz a partir de la MVP, usando DOMMatrix, es esta (htmlElementMVP y targetHTMLElement salen de tu escena):
if (canvas.getElementTransform) {
// 1. Convertir la MVP de WebGL a DOMMatrix
const mvpDOM = new DOMMatrix(Array.from(htmlElementMVP));
// 2. Normalizar el elemento HTML (pixels -> cuadrado unitario 1x1)
const width = targetHTMLElement.offsetWidth;
const height = targetHTMLElement.offsetHeight;
const cssToUnitSpace = new DOMMatrix()
.scale(1 / width, -1 / height, 1) // Reducir a tamaño unitario y flipear Y
.translate(-width / 2, -height / 2); // Centrar el elemento
// 3. Mapear al viewport del canvas
const clipToCanvasViewport = new DOMMatrix()
.translate(canvas.width / 2, canvas.height / 2) // Origen al centro
.scale(canvas.width / 2, -canvas.height / 2, 1); // Estirar a las dimensiones del canvas
// 4. Multiplicar: (Clip -> Pixels) * (MVP) * (pixels -> cuadrado unitario)
const screenSpaceTransform = clipToCanvasViewport
.multiply(mvpDOM)
.multiply(cssToUnitSpace);
// 5. Aplicar el transform resultante
const computedTransform = canvas.getElementTransform(targetHTMLElement, screenSpaceTransform);
if (computedTransform) {
targetHTMLElement.style.transform = computedTransform.toString();
}
}
No es para asustarse: es la serie de transformaciones de coordenadas que tiene cualquier pipeline 3D (clip space, NDC, screen space) expresada con DOMMatrix para que el navegador ubique el DOM real donde se está renderizando la textura. La mayoría de las veces esto lo va a resolver tu motor 3D.
Integraciones con motores
No hace falta trabajar de cero con el canvas: ya hay integraciones con Three.js y PlayCanvas. En Three.js aparece un THREE.HTMLTexture al que le pasás el elemento DOM:
const material = new THREE.MeshBasicMaterial();
material.map = new THREE.HTMLTexture(uiElement); // Pasás el elemento DOM
const geometry = new THREE.BoxGeometry(1, 1, 1);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
Y en PlayCanvas se engancha al evento paint para actualizar la fuente de la textura:
// Esperar el evento 'paint' para setear la fuente
canvas.addEventListener('paint', () => {
htmlTexture.setSource(htmlElement);
}, { once: true });
canvas.requestPaint();
// Mantenerla actualizada
canvas.addEventListener('paint', onPaintUpload);
const material = new pc.StandardMaterial();
material.diffuseMap = htmlTexture;
material.update();
Estado de estas integraciones: en la charla de I/O, Thomas Nattestad confirmó que tanto Three.js como PlayCanvas ya tienen soporte experimental para HTML-in-Canvas, así que
THREE.HTMLTexturey la API de PlayCanvas no son inventos del ejemplo. Eso sí: es soporte experimental sobre una API en origin trial, con lo cual las interfaces pueden cambiar.
Accesibilidad
Para mí lo que justifica toda la propuesta no es el efecto visual sino que tapa el agujero de accesibilidad del canvas. Como los hijos siguen siendo nodos DOM reales que participan del layout y del hit-testing, lo que normalmente anda sigue andando:
- El contenido está en el accessibility tree y lo lee un screen reader.
- El find-in-page resalta el texto, aunque esté pintado como textura WebGL.
- El texto es seleccionable y se puede copiar/pegar.
- Los context menus y las DevTools funcionan.
- Es indexable por crawlers, y de paso por agentes de IA.
Lo único que tenés que poner de tu lado es mantener el element.style.transform sincronizado con lo que dibujaste. Si lo hacés bien, el lugar donde el usuario interactúa coincide con el lugar donde ve las cosas, y el canvas deja de ser ese agujero negro de siempre.
Privacidad: qué entra y qué no en el snapshot
Dibujar HTML arbitrario en un canvas legible por JavaScript abre una puerta a fingerprinting y filtración de datos. Por eso el diseño es explícito sobre qué se pinta y qué no. El snapshot excluye información sensible que el autor de la página no debería poder observar:
- Contenido de iframes cross-origin y URLs referenciadas.
- System colors, temas y preferencias del sistema.
- Marcadores de spelling y grammar.
- Estado de visited links.
- Autofill pendiente en formularios.
- Anti-aliasing subpixel del texto.
- Preferencias de captions/subtítulos del usuario.
Y retiene lo que no es sensible: el texto de búsqueda y los text-fragment markers, la apariencia de scrollbars y form controls, el caret blink rate, y el estado del media query forced-colors.
Workers y OffscreenCanvas
En la propuesta (no confirmada como shippeada en Chrome) hay soporte para mover el render a un Worker. El método es captureElementImage(), que toma un snapshot del elemento como un objeto ElementImage transferible. Lo capturás en el main thread, lo mandás al worker, lo dibujás en un OffscreenCanvas y devolvés el transform para sincronizar el DOM:
// Worker + OffscreenCanvas con captureElementImage()
const worker = new Worker(URL.createObjectURL(new Blob([workerCode])));
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);
canvas.onpaint = () => {
const elementImage = canvas.captureElementImage(form_element);
worker.postMessage({ elementImage: elementImage }, [elementImage]);
};
// Sincronizar el CSS transform del elemento con la ubicación dibujada
worker.onmessage = ({ data }) => {
form_element.style.transform = data.transform.toString();
};
// Dentro del worker: ctx.drawElementImage(event.data.elementImage, 100, 0)
// y postMessage del transform de vuelta al main thread
Casos de uso
Hay dos tipos de apps que justifican toda la propuesta:
-
Apps web pesadas sobre canvas: Google Docs, Miro, Figma y similares renderizan UI compleja dentro de workspaces de canvas por performance. Hoy pagan un costo enorme en accesibilidad. Con esto podrían dibujar componentes HTML reales y mantener semántica gratis.
-
Escenas 3D, juegos y WebXR: poner una UI web totalmente interactiva —menús, formularios, HUDs— dentro de una escena 3D, como textura de un mesh, sin reimplementar inputs a mano. También sirve para sitios de marketing interactivos y para overlays sobre video. Abre la puerta a experiencias que hoy son carísimas de hacer accesibles (¿alguien recuerda los sitios en Flash?).
Aún en esta etapa temprana, ya hay varios explorándolo: Figma, Adobe, Miro y JetBrains arrancaron a prototipar con la API. Y del lado de la comunidad, si buscás "HTML-in-Canvas" en redes vas a encontrar un montón de experimentos —desde páginas web que se "explotan" en 3D (Santiago Colombatto) hasta los efectos de botones elásticos de Wes Bos—. Muchos son demos más cercanas al juego que a lo profesional, pero dejan claro lo expresiva que es la API. Las demos oficiales del equipo de Chrome están en chrome.dev/html-in-canvas.
Consideraciones a tener en cuenta
- Es experimental y puede cambiar. Está en origin trial alrededor de Chrome 148 a 150, aunque las versiones exactas bailan según la fuente (148-150 en el blog de Chrome, Canary 149+ para probar, y algunas fuentes hablan de Chromium 147+). Los nombres de los métodos podrían cambiar antes del lanzamiento estable; de hecho el flag se llama
canvas-draw-elementpero el método quedó comodrawElementImage(la propuesta original barajabadrawElement). - Cómo probarlo hoy. Localmente: Chrome Canary 149 o posterior con
chrome://flags/#canvas-draw-elementhabilitado. Hay demos oficiales de WebGL/WebGPU para arrancar. - No funciona cross-origin. Contenido de iframes cross-origin no se dibuja, por seguridad y privacidad. Si tu UI vive en un iframe de otro origen, esta API no te sirve.
- Scroll y animaciones atadas a JavaScript. Como el dibujo lo disparás manualmente desde JS (un snapshot por frame), el scroll y las animaciones no se actualizan independientemente del main thread como sí pasa con el DOM normal. No esperes la misma fluidez que con el scroll/animaciones.
- Hay diseño abierto. El repo del WICG tiene varios issues sin cerrar sobre hit-testing y orden de capas, integración con Shadow DOM y slots, canvases anidados, efectos compositor-driven y SSR. O sea, no es una API cerrada todavía.
- No es estándar formal. Es un explainer vivo en el WICG, no una spec del WHATWG ni del W3C. Por ahora solo está en Chromium (Chrome/Canary y derivados como Brave, detrás del flag); ningún motor no-Chromium la implementó.
Conclusión
HTML-in-Canvas ataca un dolor viejo y concreto: hasta ahora, meter contenido rico en un canvas significaba renunciar a accesibilidad, selección de texto, find-in-page y todo lo que el DOM te da gratis. La propuesta da vuelta eso permitiendo dibujar elementos HTML de verdad como si fueran píxeles, con el truco del transform para que la posición lógica y la visual coincidan.
Es temprano: experimental, detrás de un flag, solo en Chromium, con la parte 3D todavía en estado de propuesta. Pero si trabajás en una app canvas-heavy o en experiencias 3D con UI, vale la pena leer el explainer del WICG, el post del blog de Chrome, seguir el estado de la feature en ChromeStatus y dejar feedback en los issues del WICG mientras todavía se puede mover. Ahora es cuando el feedback cambia algo; una vez que estabilice, ya es demasiado tarde.
Fuentes
- "Build next-generation UIs with the HTML-in-Canvas API" — Thomas Nattestad (Google I/O 2026).
- HTML in Canvas origin trial — blog de Chrome for Developers.
- WICG/html-in-canvas — explainer y repo del estándar en incubación.
- Demos oficiales: chrome.dev/html-in-canvas.

