Quien haya montado una escena en WebGL con un formulario dentro conoce el dilema. O pintas la interfaz a mano en el canvas, y pierdes la selección de texto, la accesibilidad y los saltos de línea, o la colocas como HTML encima del canvas y te pasas la vida recalculando posiciones para que no se despegue de la escena. Desde el 26 de octubre hay una tercera vía: Chrome 157 trae de serie HTML-in-Canvas, la API que dibuja HTML vivo dentro de un <canvas> 2D, WebGL o WebGPU.
La diferencia con lo que había hasta ahora es que el navegador no hace una foto del HTML: lo sigue tratando como HTML. El texto se puede seleccionar y copiar, el lector de pantalla lo lee, Ctrl+F lo encuentra, el traductor lo traduce y el autocompletado rellena sus campos, aunque se vea deformado sobre una malla 3D. La propuesta está en el WICG desde agosto de 2024. Chrome la tuvo primero tras un flag y, desde la versión 148, en origin trial, y por el camino ha cambiado varias veces de nombres; más abajo están las equivalencias por si probaste una versión anterior.
El problema de siempre: o DOM o píxeles
Un texto dibujado con ctx.fillText() son píxeles. No hace salto de línea, no entiende de negritas ni de texto de derecha a izquierda, no se puede seleccionar y, para un lector de pantalla, no existe: hay que mantener a mano un DOM paralelo e invisible que diga lo mismo. Para internacionalizar en serio, además, toca cargar un motor de maquetación de texto en JavaScript.
Las salidas habituales tenían su precio:
- Convertir el HTML en imagen con librerías como html2canvas o dom-to-image. Lo que llega al canvas es una foto: el botón ya no es un botón. Y como la librería reimplementa el CSS por su cuenta, lo que no soporta sencillamente no aparece.
- Poner HTML flotando encima del canvas. Funciona hasta que la cámara se mueve: el DOM y los píxeles van con tiempos distintos y el panel tiembla o se queda atrás. Además, el HTML siempre queda por encima de todo, así que no puede esconderse detrás de un objeto 3D, verse a través de un cristal ni reflejarse en un espejo. Y cada fotograma hay que escribir
style.transformen cada panel, con el recálculo de estilos que eso supone.

Cómo funciona: atributos, evento paint y una llamada para dibujar
La API se apoya en muy pocas piezas. La primera es declarativa: el <canvas> lleva content="drawable", que le dice al navegador que maquete sus hijos en lugar de tratarlos como contenido de reserva, y cada elemento que se quiere dibujar lleva el atributo drawable. El HTML vive dentro del canvas, maquetado pero invisible hasta que lo pintas.
La segunda es el evento paint. El navegador lo dispara en el canvas cada vez que cambia el aspecto de algún elemento drawable: un estilo, el texto de un campo, el foco o un hover. Dentro del manejador se dibuja. Con esto basta para un ejemplo completo en 2D:
<canvas id="lienzo" width="600" height="400" content="drawable">
<div id="ficha" drawable>
<h2>Detalles del producto</h2>
<p>7,60 €</p>
<label>Email <input type="email"></label>
<button>Comprar</button>
</div>
</canvas>
<script>
const canvas = document.getElementById('lienzo');
const ctx = canvas.getContext('2d');
const ficha = document.getElementById('ficha');
canvas.addEventListener('paint', () => {
ctx.reset();
ctx.rotate(-0.1);
ctx.drawElementImage(ficha, 40, 80);
});
</script>
La ficha sale girada dentro del canvas y, aun así, el campo de email admite escritura, el botón recibe el clic en el sitio donde se ve y el texto se puede seleccionar. En 2D el navegador sincroniza solo la geometría: sabe dónde has dibujado el elemento y lleva allí los clics y los recuadros de foco.

Algunos detalles que conviene saber desde el principio:
drawElementImage()admite las mismas variantes quedrawImage(): posición; posición y tamaño; recorte del origen; y recorte más destino (3, 5, 7 y 9 argumentos).- Los
drawablese pueden anidar. Un botóndrawabledentro de una tarjetadrawablequeda fuera del dibujo de la tarjeta y se pinta con su propia llamada, así que se puede animar por separado sin romper la semántica del HTML. - El evento trae
changedElements, con los elementos que han cambiado desde el último pintado, para no redibujarlo todo. - Los cambios en el DOM hechos dentro de
paintse ven en el fotograma siguiente. Si algo tiene que verse en el actual, se cambia enrequestAnimationFrame, que corre antes. - Para animaciones continuas,
canvas.requestPaint()pide otropainten el siguiente fotograma aunque nada haya cambiado, al estilo derequestAnimationFrame().
flowchart TD
A["Cambia el HTML: estilo, texto, foco"] --> B["Evento paint en el canvas"]
B --> C["2D: drawElementImage"]
B --> D["WebGL: texElementSubImage2D"]
B --> E["WebGPU: drawElementImageToTexture"]
D --> F["updateElementGeometry"]
E --> F
C --> G["Clics, foco y accesibilidad donde se dibujó"]
F --> GWebGL: el HTML como textura
En 3D la idea es la misma, pero el elemento no se dibuja en el canvas, sino que se sube a una textura. Primero se reserva la memoria de la textura una sola vez, pasando null como datos, y después, en cada paint, se vuelca el HTML con texElementSubImage2D():
const panel = document.getElementById('panel'); // el elemento drawable
const gl = canvas.getContext('webgl2');
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
// Reserva la memoria una vez (o cuando cambie el tamaño del elemento)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 640, 400, 0,
gl.RGBA, gl.UNSIGNED_BYTE, null);
canvas.addEventListener('paint', () => {
// 1. El HTML, a la textura
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texElementSubImage2D(gl.TEXTURE_2D, 0, 0, 0, panel);
// 2. La escena, con la textura sobre la geometría que quieras
dibujarEscena();
// 3. Dónde ha quedado el panel, en píxeles CSS del canvas
const canvasTransform = new DOMMatrix().translate(x, y).scale(escala);
canvas.updateElementGeometry(panel, { canvasTransform });
});
El tercer paso es el que más se olvida. En 2D el navegador sabe dónde ha acabado el elemento; en WebGL no, porque el shader puede poner esa textura en cualquier sitio. Con updateElementGeometry() se le dice dónde está para que los clics, el foco y las herramientas de accesibilidad apunten al lugar correcto. La propia documentación de Chrome lo ilustra con una tarjeta 3D que sigue girando cuando se deja de llamar a este método: los clics siguen cayendo donde estaba antes. Para elementos de fondo existe la opción preserveHitTestOrder: true, que evita que el último actualizado se ponga el primero a la hora de recibir clics.

Una vez en la GPU, esa textura es una más: se puede curvar sobre una malla, iluminar, reflejar o pasar por un shader. La imagen de portada de este artículo es el mismo formulario ondulado por un shader de vértices y sombreado según la curva.
En WebGPU la llamada es drawElementImageToTexture(), en la cola del dispositivo, y la sincronización de la geometría se hace igual, con updateElementGeometry():
canvas.addEventListener('paint', () => {
device.queue.drawElementImageToTexture(
{ source: panel },
{ texture: textura }
);
dibujarEscena();
canvas.updateElementGeometry(panel, { canvasTransform });
});
Qué conserva el HTML dentro del canvas
Según Chrome, el contenido dibujado así mantiene:
- Maquetación de texto completa: varias líneas, texto bidireccional y CSS.
- Selección, copiar y pegar, y menú contextual, también sobre una superficie 3D.
- El árbol de accesibilidad, para lectores de pantalla.
- Buscar en la página, traducción y autocompletado, incluso dentro de texturas WebGL y WebGPU.
- Las extensiones: una que sustituya textos cambia también el que se ve en la malla 3D.
- La inspección y edición de estilos en DevTools.
- La indexación por rastreadores y agentes de IA, que ven DOM en lugar de una imagen.
- La exportación del canvas completo con
toDataURL(),toBlob()o, en vídeo,captureStream().
Con la accesibilidad hay un matiz. Un elemento drawable que todavía no se ha dibujado, o cuya geometría no se ha actualizado, llega al árbol de accesibilidad sin posición: el lector de pantalla puede leerlo, pero una lupa sabe que no está en pantalla. Si dejas en el canvas vistas que ya no dibujas, conviene quitarlas del DOM o marcarlas con aria-hidden="true" o inert.
Lo que el navegador no dibuja
Hay una restricción que no aparece en los ejemplos y que puede dar sorpresas. Como el contenido de un canvas se puede leer píxel a píxel (con getImageData() o desde WebGL), la API no pinta nada que permita espiar al usuario. Según la propuesta del WICG, quedan fuera:
- El contenido de otro origen: imágenes, iframes, fondos CSS con
url(), SVG externos o canvas «contaminados». Un iframe del mismo origen sí se pinta, pero no lo que traiga de otros. - Los colores, el tema y las preferencias del sistema.
- Las marcas del corrector ortográfico y gramatical.
- Si un enlace ya se ha visitado.
- Los datos de autocompletado que aún no se han confirmado.
- El suavizado de texto subpíxel, los subtítulos y controles de vídeo y las ventanas del método de entrada (IME).
En la práctica: si tu tarjeta muestra una imagen servida desde un CDN de otro dominio, en el canvas no aparecerá. Las marcas de buscar en la página, el cursor de texto y la apariencia de los controles de formulario sí se dibujan.
Píxeles nítidos: el detalle que más se olvida
Si el texto sale borroso, casi siempre es porque la cuadrícula del canvas no coincide con los píxeles físicos de la pantalla. El HTML se maqueta en píxeles CSS, el canvas tiene su propia cuadrícula (width y height) y la pantalla tiene sus píxeles reales; cada conversión que no cuadra suaviza el texto una vez más. La recomendación de Chrome es dimensionar el canvas con un ResizeObserver sobre device-pixel-content-box:
let escalaX = 1, escalaY = 1; // píxeles del canvas por píxel CSS
const observer = new ResizeObserver(([entry]) => {
const dpc = entry.devicePixelContentBoxSize;
const { width, height } = entry.contentRect;
canvas.width = dpc ? dpc[0].inlineSize : Math.round(width * devicePixelRatio);
canvas.height = dpc ? dpc[0].blockSize : Math.round(height * devicePixelRatio);
escalaX = canvas.width / width;
escalaY = canvas.height / height;
});
observer.observe(canvas, { box: 'device-pixel-content-box' });
Ojo con las unidades: las coordenadas de destino de drawElementImage() van en píxeles del canvas, mientras que offsetWidth, getBoundingClientRect(), los eventos de puntero y el canvasTransform de updateElementGeometry() van en píxeles CSS. Para centrar un elemento hay que multiplicar sus medidas por escalaX y escalaY. Y como se dibuja la caja de borde del elemento, box-sizing: border-box evita que el relleno lo haga más grande de lo que esperas.
Si lo probaste durante el origin trial, revisa el código
Chrome 150 y Chrome 155 cambiaron la API a partir de los comentarios de desarrolladores y de los grupos de estándares. Lo escrito para las primeras versiones no funciona tal cual:
| Antes | Ahora (Chrome 155+) |
|---|---|
<canvas layoutsubtree> | <canvas content="drawable"> y drawable en cada elemento |
| Solo se dibujaban hijos directos del canvas | Cualquier descendiente con drawable, anidables |
drawElementImage() devolvía una matriz que había que copiar en style.transform | No devuelve nada: en 2D se sincroniza solo (salvo con preserveElementGeometry: true) |
WebGL: texElementImage2D() | texElementSubImage2D(), con desplazamiento y memoria reservada antes con texImage2D(…, null) |
WebGPU: copyElementImageToTexture() | drawElementImageToTexture(), con sourceX, sourceWidth… y size |
El blog de Chrome publicó fragmentos para convivir con varias versiones a la vez, comprobando, por ejemplo, si existe 'texElementSubImage2D' in WebGL2RenderingContext.prototype antes de elegir la llamada.
Detectar el soporte y tener plan B
De momento es cosa de Chrome. Mozilla tiene abierta la petición de posición sobre la propuesta desde septiembre de 2024 y WebKit desde marzo de 2026, las dos sin veredicto. Cualquier uso en producción necesita detección de características. La que propone Chrome:
export function supportsHtmlInCanvas() {
if (typeof document === 'undefined') return false;
const canvas = document.createElement('canvas');
canvas.setAttribute('content', 'drawable');
return canvas.content === 'drawable';
}
Si devuelve false, las alternativas son las de siempre: una interfaz distinta, HTML flotando sobre el canvas, la librería three-html-render, html2canvas o dom-to-image. Ninguna de esas librerías es de Google; son proyectos de la comunidad.
Three.js, Babylon.js, PixiJS y PlayCanvas
Escribir a mano las matrices de cada panel cansa pronto, y los motores ya se han puesto a ello:
- Three.js: soporte experimental con
THREE.HTMLTexture. - Babylon.js: clase
BABYLON.HtmlTexture. - PixiJS:
HTMLSourcedesde la versión 8.19.0. - PlayCanvas: texturas que se alimentan del HTML con
setSource(). - CanvasUI: una librería de componentes de código abierto pensada para HTML-in-Canvas.
Con Three.js, poner un elemento HTML en las caras de un cubo queda así:
const material = new THREE.MeshBasicMaterial();
material.map = new THREE.HTMLTexture(panelHtml);
const cubo = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material);
scene.add(cubo);
El grado de madurez varía de un motor a otro; antes de apostar por uno, conviene mirar su documentación.
Para qué sirve (y para qué no)
Donde más se va a notar es en las aplicaciones que ya viven dentro de un canvas: editores gráficos, pizarras, hojas de cálculo o herramientas de diseño que hoy dibujan sus propios menús y cuadros de texto. Ahí pueden usar HTML de verdad, ganar accesibilidad y quitarse de encima el código que imitaba lo que el navegador ya sabe hacer. También en los juegos y en WebXR, con terminales dentro del juego o libros que se leen en la escena; en gráficos acelerados por GPU con leyendas y etiquetas de varias líneas; en configuradores de producto con el precio y las opciones pegados al modelo 3D, y en herramientas creativas que mezclan tipografía HTML con shaders y exportan el resultado a imagen o vídeo.
Para una web normal no aporta nada. Un blog, una tienda o un panel de administración están mejor en el DOM de toda la vida, que es más sencillo, más rápido de mantener y funciona en todos los navegadores. HTML-in-Canvas tiene sentido cuando ya hay un canvas y hasta ahora el HTML se quedaba fuera.
Cómo hicimos las capturas
Las imágenes de este artículo no son montajes: son capturas de dos pequeñas demos que escribimos para probar la API. Las hicimos con Chrome 156, que ya trae la implementación tras el flag chrome://flags/#canvas-draw-element (o arrancando el navegador con --enable-blink-features=CanvasDrawElement); en Chrome 157 no hace falta nada. La demo WebGL no usa ningún motor, solo WebGL2: una malla de 64×64 cuadros con el formulario como textura, el reflejo dibujado invirtiendo la geometría y un shader de onda para la portada. Lo que más nos sorprendió fue ver la selección de texto del campo de búsqueda viajar con la textura, curvarse con la onda y reflejarse en el suelo sin escribir una sola línea para ello.
Fuentes
- HTML-in-Canvas in Chrome 157: Interactive DOM inside 2D, WebGL, and WebGPU (Chrome for Developers, 26 de octubre de 2026)
- Documentación de HTML-in-Canvas: conceptos, 2D, WebGL y WebGPU
- Cambios de la API en Chrome 150 y 155
- Propuesta del WICG, con la sección de privacidad y demos en vivo
- Colección de demos de la comunidad
- Posiciones de Mozilla y WebKit (abiertas a 30 de octubre de 2026)