HTML-in-Canvas llega a Chrome 157: HTML vivo dentro de Canvas, WebGL y WebGPU

HTML-in-Canvas llega a Chrome 157: HTML vivo dentro de Canvas, WebGL y WebGPU

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.transform en cada panel, con el recálculo de estilos que eso supone.
Comparativa: captura estática con html2canvas frente a HTML vivo con HTML-in-Canvas
Antes y ahora: la captura de una librería es una imagen; con HTML-in-Canvas el navegador sigue tratando el contenido como HTML.

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.

La misma ficha pintada con fillText y con drawElementImage dentro de un canvas 2D
Nuestra prueba: a la izquierda, la ficha pintada con fillText() (el texto se sale, sin negritas ni alineación de derecha a izquierda); a la derecha, el mismo HTML con drawElementImage(), con salto de línea, enlace y una selección de texto real.

Algunos detalles que conviene saber desde el principio:

  • drawElementImage() admite las mismas variantes que drawImage(): posición; posición y tamaño; recorte del origen; y recorte más destino (3, 5, 7 y 9 argumentos).
  • Los drawable se pueden anidar. Un botón drawable dentro de una tarjeta drawable queda 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 paint se ven en el fotograma siguiente. Si algo tiene que verse en el actual, se cambia en requestAnimationFrame, que corre antes.
  • Para animaciones continuas, canvas.requestPaint() pide otro paint en el siguiente fotograma aunque nada haya cambiado, al estilo de requestAnimationFrame().
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 --> G

WebGL: 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.

Un formulario HTML real convertido en textura WebGL, en perspectiva y con reflejo
Un formulario HTML real como textura WebGL, en perspectiva y reflejado en el suelo. El texto seleccionado del campo de búsqueda viaja con la textura.

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:

AntesAhora (Chrome 155+)
<canvas layoutsubtree><canvas content="drawable"> y drawable en cada elemento
Solo se dibujaban hijos directos del canvasCualquier descendiente con drawable, anidables
drawElementImage() devolvía una matriz que había que copiar en style.transformNo 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: HTMLSource desde 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