HTML-in-Canvas: Conceptos técnicos

La API de HTML en Canvas te permite renderizar subárboles del DOM interactivos y accesibles directamente en contextos de lienzo de 2D, WebGL o WebGPU.

En esta guía, se describen los conceptos técnicos de HTML-in-Canvas que son comunes en todos los contextos de canvas:

Prepara tu lienzo para renderizar HTML

Para renderizar elementos HTML dentro de un <canvas>, configura los siguientes dos atributos:

  • Establece content="drawable" en <canvas>: Esto hace que el elemento canvas diseñe sus nodos del DOM descendientes en lugar de tratarlos solo como contenido oculto.
  • Establece drawable en los elementos de destino: Esto marca un elemento como una raíz de pintura independiente apta para la renderización del lienzo.
<canvas id="mainCanvas" width="600" height="400" content="drawable">
  <div id="parentElement" class="card" drawable>
    <h2>Container Title</h2>
    <p>Descriptive text inside the parent element.</p>
    <!-- Nested drawable: excluded from parentElement and drawn independently -->
    <button id="childElement" class="btn" drawable>Action Button</button>
  </div>
</canvas>

Comprende el subárbol de elementos de diseño

Cuando prepares el HTML para renderizarlo en un lienzo, debes decidir qué elementos se deben dibujar de forma independiente. Cada elemento drawable representa una raíz de pintura independiente y su subárbol de elementos de diseño.

Un subárbol de elementos de diseño consta de un elemento con el atributo drawable y todos sus descendientes, excepto los descendientes anidados que también llevan drawable, por ejemplo:

<canvas content="drawable">
  <!-- Subtree A (Renders parent container and text) -->
  <div id="parentElement" drawable>
    <h2>Parent Card Heading</h2>
    <p>Card body text content.</p>
    <!-- Subtree B (Excluded from Subtree A and drawn separately) -->
    <button id="nestedChildElement" drawable>
      Clickable Button
    </button>
  </div>
</canvas>
Elemento Límite de pintura Comportamiento
#parentElement (subárbol A) Panel de fondo, encabezado y párrafo Se renderiza cuando se llama a drawElementImage(parentElement, ...)
#nestedChildElement (subárbol B) Superficie y texto del botón Se excluye del subárbol de #parentElement y se renderiza en una llamada a drawElementImage(nestedChildElement, ...) independiente.
Un botón drawable está anidado dentro de una tarjeta drawable, y cada uno se dibuja con su propia llamada a drawElementImage().

Anidar elementos de dibujo te permite mantener relaciones semánticas de HTML y, al mismo tiempo, te da la libertad de colocar en capas, posicionar o animar elementos secundarios de forma independiente en la escena del lienzo.

Información sobre el evento paint

La API de HTML-in-Canvas presenta el evento paint. El navegador envía automáticamente un evento paint en el <canvas> cada vez que cambian el diseño, el texto o los estilos CSS en cualquiera de sus descendientes drawable.

canvas.addEventListener("paint", (event) => {
  // Clear the viewport
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // Redraw elements individually
  ctx.drawElementImage(parentElement, 20, 20);
  ctx.drawElementImage(childElement, 40, 90);
});

El evento paint envía una instancia de CanvasPaintEvent, que extiende el Event del DOM estándar y tiene una propiedad changedElements que contiene solo los descendientes de drawable que cambiaron visualmente (por ejemplo, cambios de estilo, cambios de diseño, ediciones de texto o cambios de estado de enfoque) desde la última pintura.

canvas.addEventListener("paint", (event) => {
  // Array of changed elements
  for (const el of event.changedElements) {
    console.log(`Element #${el.id} visually changed and needs repainting`);
  }
});
El navegador activa la pintura en el lienzo cada vez que cambia la renderización de un elemento secundario dibujable.

Ciclo de vida de los fotogramas

Los comandos de dibujo de lienzo ejecutados dentro del evento paint aparecen en la actualización de renderización actual, pero las modificaciones del DOM realizadas dentro del evento paint no se muestran hasta el siguiente fotograma:

canvas.addEventListener("paint", () => {
  // Renders in the CURRENT rendering update:
  ctx.drawElementImage(parentElement, 20, 20);

  // Modifying DOM styles inside paint schedules a new layout cycle;
  // changes will appear in the NEXT frame:
  parentElement.style.backgroundColor = "#e0f2fe";
});

Lienzos anidados

Si tu página contiene elementos <canvas content="drawable"> anidados, el navegador envía eventos paint en orden inverso al árbol del DOM (los descendientes se activan antes que los ancestros). Esto garantiza que los elementos secundarios del lienzo se rendericen antes de que los elementos superiores los compongan.

Supongamos que, en el siguiente ejemplo, el elemento nestedElement interno debe cambiar visualmente (por ejemplo, su color de fondo de CSS cambia cuando el usuario mantiene el puntero sobre él). Primero, se activa el evento paint en el lienzo nestedCanvas. Su objeto de escucha borra el búfer secundario y llama a ctx.drawElementImage(nestedElement, ...) para renderizar el estado actualizado. Luego, el evento paint se activa en parentCanvas. Su objeto de escucha dibuja wrapperElement (que contiene el lienzo nestedCanvas ya actualizado) en la escena.

<!-- Outer parent canvas -->
<canvas id="parentCanvas" width="800" height="600" content="drawable">
  <!-- Wrapper container for nested content -->
  <div id="wrapperElement" drawable>

    <!-- Nested child canvas -->
    <canvas id="nestedCanvas" width="300" height="200" content="drawable">
      <div id="nestedElement" class="badge" drawable>
        Inner Child Content
      </div>
    </canvas>

  </div>
</canvas>

<script>
const parentCanvas = document.getElementById("parentCanvas");
const parentCtx = parentCanvas.getContext("2d");

const nestedCanvas = document.getElementById("nestedCanvas");
const nestedCtx = nestedCanvas.getContext("2d");

const nestedElement = document.getElementById("nestedElement");
const wrapperElement = document.getElementById("wrapperElement");

// ============================================================================
// 1. Child Canvas Listener (Fires FIRST)
// ============================================================================
nestedCanvas.addEventListener("paint", (event) => {
  // Clear and redraw the child canvas with the updated inner element
  nestedCtx.clearRect(0, 0, nestedCanvas.width, nestedCanvas.height);
  nestedCtx.drawElementImage(nestedElement, 10, 10);

  console.log("1. Child nested canvas rendered fresh pixels.");
});

// ============================================================================
// 2. Parent Canvas Listener (Fires SECOND)
// ============================================================================
parentCanvas.addEventListener("paint", (event) => {
  // Clear and redraw the parent canvas scene
  parentCtx.clearRect(0, 0, parentCanvas.width, parentCanvas.height);

  // Draws the wrapper container (including the freshly painted nestedCanvas)
  parentCtx.drawElementImage(wrapperElement, 50, 50);

  console.log("2. Parent canvas composited the complete scene.");
});
</script>
Cuando se anidan elementos <canvas content="drawable">, los eventos de pintura se activan en orden inverso al árbol: el lienzo anidado se pinta primero, de modo que el lienzo superior pueda dibujar sus píxeles actualizados.

Activa un evento de paint

Llama a requestPaint() para programar un solo evento paint en el siguiente fotograma, incluso si no cambió ningún texto o estilo del DOM. Esto se puede usar para renderizar escenas interactivas con elementos en movimiento, por ejemplo:

let animatedX = 0;

canvas.addEventListener("paint", () => {
  animatedX += 1.5; // Move the element horizontally
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawElementImage(staticElement, 20, 20);
  ctx.drawElementImage(movingElement, animatedX, 200);
  // Request canvas paint on each rendering update for smooth animation
  canvas.requestPaint();
});

canvas.requestPaint();

Actualiza la geometría de un elemento

Cuando dibujas un elemento HTML en un lienzo, tu código de dibujo decide dónde aparece el elemento y qué tan grande es. A diferencia del contenido HTML normal, la posición y el tamaño del elemento en pantalla, su geometría, no se pueden determinar solo a partir de su estilo calculado. El navegador necesita la geometría de un elemento para hacer lo siguiente:

  • Enviar eventos de entrada, como clics, presiones y movimientos del puntero, al elemento correcto Esto se denomina prueba de posicionamiento.
  • Devuelve resultados exactos de las consultas de diseño, como getBoundingClientRect().
  • Informa los límites del elemento a las tecnologías de accesibilidad, como las lupas de pantalla.

En 2D, drawElementImage() actualiza la geometría automáticamente de forma predeterminada. Los argumentos que le pasas, junto con el estilo calculado del elemento y la transformación actual del contexto, son suficientes para determinar dónde aparece el elemento.

En los contextos de WebGL y WebGPU, el navegador no puede determinar la geometría. Un elemento HTML se copia en una textura y, luego, tu programa de sombreadores puede asignar esa textura a la escena de cualquier manera arbitraria. Para indicarle al navegador dónde aparece el elemento, pasa una transformación a updateElementGeometry().

Cuando el código deja de llamar a updateElementGeometry(), el navegador ya no sabe dónde están los elementos HTML en la tarjeta 3D. La tarjeta sigue girando, pero los clics se dirigen a donde solía estar.

Para actualizar la geometría, pasa una transformación espacial al método. Aprende a construir una transformación para diferentes contextos:

Administra el orden de apilamiento de las pruebas de posicionamiento

De forma predeterminada, cada vez que se actualiza la geometría de un elemento, el navegador lo lleva a la parte superior de la pila de interacción (el último elemento dibujado es el objetivo de clic más alto). Puedes controlar este comportamiento con preserveHitTestOrder:

// Updates the backdrop without stealing clicks from foreground elements
canvas.updateElementGeometry(backgroundElement, {
  canvasTransform: customTransform,
  preserveHitTestOrder: true
});
Opción Comportamiento Caso de uso
preserveHitTestOrder: false (predeterminada) Mueve el elemento a la parte superior de la pila de pruebas de posicionamiento. Elementos en primer plano que deberían recibir clics primero
preserveHitTestOrder: true Deja el elemento en su prioridad de pila actual. Son elementos en segundo plano que se deben actualizar después de los elementos en primer plano.

Restablecer geometría

Para dejar de dibujar un elemento sin quitarlo del DOM, llama a canvas.clearElementGeometry(). Esto restablece la transformación del lienzo del elemento y lo marca como fuera de la pantalla en el árbol de accesibilidad:

// Remove from canvas hit-testing and mark as offscreen
canvas.clearElementGeometry(childElement);

Tamaño y unidades

Cuando dibujas HTML en un lienzo, el navegador opera en tres sistemas de coordenadas a la vez: píxeles de CSS, píxeles del dispositivo y píxeles de la cuadrícula del lienzo.

Los píxeles de CSS se usan para medir y posicionar el contenido durante el diseño. Luego, el navegador los escala por devicePixelRatio para rasterizar la página en píxeles de dispositivo físico. Como los píxeles de CSS son una unidad lógica, las posiciones y los tamaños suelen terminar como fracciones de un píxel de dispositivo (por ejemplo, 10.5 px de CSS × un DPR de 1.5 = 15.75 px de dispositivo). Una pantalla solo puede colorear píxeles enteros, por lo que el navegador tiene que resolver todo lo que se encuentre entre ellos. Para ello, hace lo siguiente:

  • Ajuste: Los límites de los cuadros, los bordes y los desplazamientos de las capas se redondean al píxel de dispositivo entero más cercano para que se mantengan nítidos.
  • Suavizado de bordes: En el caso de los glifos de texto, las curvas y los bordes transformados, la cobertura parcial se combina con los píxeles vecinos para que sigan viéndose suaves y en el lugar correcto.

Un <canvas> tiene su propia cuadrícula de píxeles, establecida por sus atributos width y height, que es independiente de los píxeles de CSS y de los píxeles del dispositivo. Para mostrarlo, el navegador vuelve a muestrear el mapa de bits del lienzo en la cuadrícula de píxeles físicos y combina los píxeles del lienzo vecinos para completar cada píxel del dispositivo. Para obtener información sobre cómo el navegador asigna píxeles de CSS, de lienzo y de dispositivo, consulta Renderización perfecta con devicePixelContentBox.

Todos los elementos HTML drawable se disponen en píxeles CSS. Debido a que el contenido dibujado en un lienzo puede pasar por dos conversiones, de píxeles de CSS a la cuadrícula del lienzo y, luego, de la cuadrícula del lienzo a los píxeles físicos, cada paso que no se alinea exactamente agrega otra ronda de combinación, y el resultado puede verse borroso. Para evitarlo, haz que la cuadrícula del lienzo coincida con la cuadrícula de píxeles físicos. Esto tiene dos propósitos:

  • Renderización coherente: Si el lienzo se asigna en una proporción de 1:1 a los píxeles físicos, la conversión de píxeles de CSS en píxeles de lienzo produce el mismo resultado que la conversión del navegador de píxeles de CSS en píxeles físicos para HTML normal.
  • Sin combinación adicional: Si el lienzo se asigna en una proporción de 1:1 a los píxeles físicos, convertirlo a píxeles del dispositivo no agrega ningún desenfoque.

En la siguiente imagen, se muestra una conversión de píxeles que genera contenido borroso. Las tres cuadrículas no se alinean: el elemento de lienzo es de 360 × 180 px de CSS, y el tamaño del lienzo es de 300 × 150 píxeles de cuadrícula de lienzo. Con una proporción de píxeles del dispositivo de 1.5, el lienzo cubre 540 × 270 píxeles del dispositivo. Un píxel de CSS abarca 1.5 píxeles de dispositivo, y un píxel de lienzo abarca 1.2 píxeles de CSS o 1.8 píxeles de dispositivo (panel 1). Cuando se dibuja el círculo, su posición y radio se expresan en píxeles de lienzo, no en píxeles de CSS, por lo que el navegador lo dibuja en el mapa de bits de lienzo de 300 × 150 y suaviza el borde (panel 2). Para mostrar el lienzo, el navegador estira este mapa de bits sobre 540 × 270 píxeles del dispositivo, por lo que el navegador tiene que interpolar: cada píxel del dispositivo toma una mezcla ponderada de los píxeles del lienzo más cercanos, que ya contienen el borde con suavizado de contorno. El borde se combina por segunda vez y se extiende por varios píxeles del dispositivo, por lo que el círculo se ve borroso (panel 3). Esto se podría evitar si la cuadrícula del lienzo coincidiera con los píxeles del dispositivo en una proporción de 1:1. Luego, el navegador aplicaría el suavizado de bordes solo una vez, directamente en la cuadrícula del dispositivo, y mostraría el mapa de bits sin volver a muestrearlo, de modo que el borde se mantenga nítido (panel 4):

Diagrama de cuatro paneles en el que se comparan las cuadrículas de píxeles de un borde curvo: 1. Cuadrículas no alineadas de CSS (360 por 180), lienzo (300 por 150) y dispositivo (540 por 270). 2. La curva con suavizado de contorno en la cuadrícula de lienzo de 300 por 150. 3. Ese mapa de bits se interpoló en la cuadrícula del dispositivo de 540 por 270, lo que aplicó un suavizado por segunda vez y desenfocó el borde. 4. Un lienzo de 540 por 270 que coincide con la cuadrícula del dispositivo 1 a 1, con un suavizado de bordes para que se vea nítido.
Cómo las cuadrículas de píxeles de CSS, lienzo y dispositivo no alineadas causan un doble suavizado (paneles 1 a 3), en comparación con una cuadrícula de lienzo a dispositivo de 1:1 (panel 4).

Para que la renderización sea nítida, la cuadrícula del lienzo debe coincidir con la cantidad de píxeles del dispositivo que cubre el lienzo: 540 × 270 en el ejemplo anterior.

Comparación en paralelo de una tarjeta HTML que dice &quot;Hello from the DOM!&quot; Esta tarjeta es HTML dibujado en un lienzo.' Izquierda: Se dibuja en la cuadrícula de lienzo predeterminada de 300 por 150 y se ve borrosa. Derecha: Se dibujó en una cuadrícula de lienzo de 800 por 400 con el tamaño de ResizeObserver, con un aspecto nítido.
El mismo lienzo, de 400 x 200 píxeles de CSS, en una pantalla con un DPR de 1.5. Izquierda: la cuadrícula del lienzo de 400 x 200. Derecha: Ajustada a la DPR, una cuadrícula de 600 × 300.

Cómo ajustar el tamaño de la cuadrícula del lienzo con un observador de cambio de tamaño

El tamaño de cuadrícula correcto depende del dispositivo y puede cambiar cuando se modifica el diseño, cuando el usuario acerca la página o cuando mueve la ventana a otra pantalla. Por lo tanto, en lugar de codificar de forma rígida el tamaño de la cuadrícula, deja que el navegador te indique cuál debería ser.

Para mantener la cuadrícula del lienzo sincronizada con el lienzo en pantalla, siempre asígnale un tamaño con un ResizeObserver y ajusta la cuadrícula del lienzo a devicePixelContentBoxSize:

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);

Convierte píxeles de CSS en coordenadas de cuadrícula de lienzo

Un controlador de paint suele combinar las mediciones del DOM con las llamadas de dibujo, por ejemplo, para posicionar un elemento según su tamaño. Ambos usan unidades diferentes:

  • Los píxeles del lienzo describen la cuadrícula del lienzo, cuyo tamaño se define con canvas.width y canvas.height. Por ejemplo, los parámetros dx, dy, dwidth y dheight de los métodos de dibujo drawElementImage(), o bien otros parámetros de las APIs de Canvas, como fillRect() y translate().
  • Los píxeles de CSS describen la página. Las mediciones del DOM, como offsetWidth y clientWidth, y las coordenadas de eventos de puntero, como offsetX, se expresan en píxeles de CSS.

A continuación, se incluye una descripción general de las unidades que usan varios parámetros de la API:

Valor Unidades
canvas.width y canvas.height Píxeles de la cuadrícula del lienzo
Parámetros de dibujo en 2D, incluidos dx, dy, dwidth y dheight de drawElementImage() Píxeles de la cuadrícula del lienzo
width y height devueltos por canvas.getElementImageDefaultSize() Píxeles de la cuadrícula del lienzo
Rectángulos de origen: sx, sy, swidth y sheight de drawElementImage() y texElementSubImage2D(); sourceX, sourceY, sourceWidth y sourceHeight de drawElementImageToTexture() Píxeles de CSS
Tamaños de texturas: opciones width y height de texElementSubImage2D(); size de drawElementImageToTexture() Píxeles de textura. De forma predeterminada, es el tamaño del elemento en píxeles de la cuadrícula del lienzo, redondeado hacia arriba.
Geometría del elemento: getElementTransform(), y las opciones canvasTransform y canvasClip de updateElementGeometry() Píxeles CSS
Mediciones del DOM, como offsetWidth, clientWidth, getBoundingClientRect(), coordenadas de eventos de puntero y contentBoxSize de una entrada de observador de cambio de tamaño Píxeles de CSS
devicePixelContentBoxSize de una entrada de observador de cambio de tamaño Píxeles del dispositivo

Si usas las unidades incorrectas, es posible que el tamaño del contenido HTML que se dibuja en el lienzo no cumpla con tus expectativas. Debes convertir el valor de píxeles de CSS en píxeles de cuadrícula de lienzo cuando dibujes HTML en el lienzo. Para ello, calcula la proporción entre el tamaño de la cuadrícula del lienzo y el tamaño de CSS del lienzo: canvas.width / cssContentWidth horizontalmente y canvas.height / cssContentHeight verticalmente. Tu observador de cambio de tamaño es un lugar conveniente para calcular la proporción de la siguiente manera:

// The number of canvas grid pixels per CSS pixel
let scaleX = 1;
let scaleY = 1;

const observer = new ResizeObserver(([entry]) => {
  const dpc = entry.devicePixelContentBoxSize;
  const cssContentWidth = entry.contentRect.width;
  const cssContentHeight = entry.contentRect.height;

  // Best practice: make the canvas grid match the physical pixel grid
  if (dpc) {
    canvas.width = dpc[0].inlineSize;
    canvas.height = dpc[0].blockSize;
  } else {
    canvas.width = Math.round(cssContentWidth * window.devicePixelRatio);
    canvas.height = Math.round(cssContentHeight * window.devicePixelRatio);
  }

  scaleX = canvas.width / cssContentWidth;
  scaleY = canvas.height / cssContentHeight;
});

const supportsDevicePixelContentBox =
  typeof ResizeObserverEntry !== 'undefined' &&
  'devicePixelContentBoxSize' in ResizeObserverEntry.prototype;
const options = supportsDevicePixelContentBox ? { box: 'device-pixel-content-box' } : {};
observer.observe(canvas, options);

Luego, multiplica los valores de píxeles de CSS por scaleX y scaleY antes de pasarlos al método de dibujo. Por ejemplo, para centrar un elemento de diseño en el lienzo, haz lo siguiente:

const ctx = canvas.getContext('2d');
const card = document.getElementById('card');

canvas.onpaint = () => {
  ctx.reset();

  // offsetWidth and offsetHeight are in CSS pixels.
  // Convert them to canvas grid coordinates.
  const width = card.offsetWidth * scaleX;
  const height = card.offsetHeight * scaleY;
  const x = (canvas.width - width) / 2;
  const y = (canvas.height - height) / 2;

  // drawElementImage() takes canvas grid coordinates.
  ctx.drawElementImage(card, x, y);
};

Por último, como drawElementImage() dibuja el cuadro de borde del elemento, usa box-sizing: border-box para incluir el padding y el borde en las propiedades width y height de la tarjeta, de modo que se dibuje con el tamaño que estableciste:

#card {
  /* Make width and height include padding and border */
  box-sizing: border-box;
  width: 200px;
  padding: 16px;
}

Comprende la accesibilidad de HTML-in-Canvas

Por lo general, el contenido HTML de un <canvas content="drawable"> se expone a la accesibilidad de la misma manera que el DOM normal, lo que significa que se representa en el árbol de accesibilidad, a menos que se oculte de forma explícita con atributos como aria-hidden="true" o inert. Sin embargo, hay una excepción clave: un elemento drawable cuya geometría renderizada aún no se estableció ni actualizó.

Un elemento drawable dentro de un <canvas content="drawable"> carece de geometría renderizada si se cumple alguna de las siguientes condiciones:

  • Aún no se renderizó con drawElementImage(), que actualiza la geometría automáticamente de forma predeterminada.
  • Su geometría no se actualizó de forma explícita con updateElementGeometry().

Si un elemento drawable carece de geometría renderizada, este elemento drawable y su subárbol drawable se informan al árbol de accesibilidad sin información de geometría. Esto es útil, por ejemplo, para permitir que los lectores de pantalla accedan al contenido semántico, mientras se indica a las herramientas de asistencia visual (como las lupas) que filtren los elementos no dibujados.

En el siguiente ejemplo, se conserva la semántica de una figura, una imagen y una leyenda, mientras que se dibujan la imagen y la figura por separado:

<canvas id="canvas" width="400" height="300" content="drawable">
  <figure id="figure">
    <img drawable id="image" src="..." alt="image alt text" />
    <figcaption drawable id="caption">A caption</figcaption>
  </figure>
</canvas>
<script>
  const ctx = canvas.getContext('2d');

  canvas.onpaint = () => {
    ctx.reset();
    // Note: Because `preserveElementGeometry` defaults to false, the
    // `drawElementImage` call automatically updates geometry, ensuring
    // the image and caption have geometry information for accessibility.
    ctx.drawElementImage(image, 0, 0);
    ctx.drawElementImage(caption, 0, 200);
  };
</script>

Estrategias de resguardo

La API de HTML en Canvas se envió en Chrome 157 y es posible que no sea compatible con todos los navegadores. Para asegurarte de que tu solución funcione sin problemas para todo el mundo, verifica si el navegador admite la API y, si es necesario, recurre a una solución alternativa:

export function supportsHtmlInCanvas() {
  if (typeof document === 'undefined') return false;
  const canvas = document.createElement('canvas');
  canvas.setAttribute('content', 'drawable');
  return canvas.content === 'drawable';
}

Si la API no es compatible, según tus objetivos de implementación, puedes elegir una de las siguientes alternativas:

Próximos pasos