HTML-in-Canvas: conceitos técnicos

A API HTML-in-Canvas permite renderizar subárvores DOM interativas e acessíveis diretamente em contextos de tela 2D, WebGL ou WebGPU.

Neste guia, descrevemos os conceitos técnicos da HTML-in-Canvas que são comuns a todos os contextos de tela:

Preparar a tela para renderizar HTML

Para renderizar elementos HTML em um <canvas>, configure os dois atributos a seguir:

  • Defina content="drawable" no <canvas>:isso faz com que o elemento de tela organize os nós DOM descendentes em vez de tratá-los apenas como conteúdo oculto.
  • Defina drawable nos elementos de destino:isso marca um elemento como uma raiz de pintura independente qualificada para renderização de tela.
<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>

Entender a subárvore de drawable

Ao preparar o HTML para ser renderizado em uma tela, você precisa decidir quais elementos devem ser desenhados de forma independente. Cada elemento drawable representa uma raiz de pintura independente e a subárvore drawable dela.

Uma subárvore drawable consiste em um elemento com o atributo drawable e todos os descendentes dele, exceto os descendentes aninhados que também têm drawable, por exemplo:

<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 Pintar limite Comportamento
#parentElement (Subárvore A) Painel de segundo plano, título e parágrafo Renderizado ao chamar drawElementImage(parentElement, ...)
#nestedChildElement (Subárvore B) Superfície e texto do botão Excluído da subárvore #parentElement; renderizado em uma chamada drawElementImage(nestedChildElement, ...) separada
Um botão drawable está aninhado em um card drawable, e cada um é desenhado pela própria chamada drawElementImage().

Ao aninhar elementos desenháveis, você mantém as relações semânticas do HTML e tem a liberdade de criar camadas, posicionar ou animar elementos filhos de forma independente na cena da tela.

Entender o evento paint

A API HTML-in-Canvas apresenta o evento paint. O navegador envia automaticamente um evento paint no <canvas> sempre que o layout, o texto ou os estilos CSS mudam em qualquer um dos descendentes de 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);
});

O evento paint envia uma instância de CanvasPaintEvent, que estende o Event do DOM padrão e tem uma propriedade changedElements que contém apenas os descendentes de drawable que mudaram visualmente (por exemplo, mudanças de estilo, mudanças de layout, edições de texto ou mudanças de estado de foco) desde a ú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`);
  }
});
O navegador dispara a pintura na tela sempre que a renderização de um descendente desenhável muda.

Ciclo de vida do frame

Os comandos de desenho de tela executados no evento paint aparecem na atualização de renderização atual, mas as modificações do DOM feitas no evento paint só aparecem no frame seguinte:

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

Telas aninhadas

Se a página tiver elementos <canvas content="drawable"> aninhados, o navegador vai enviar eventos paint na ordem inversa da árvore do DOM (os descendentes são disparados antes dos ancestrais). Isso garante que os elementos de tela filhos sejam renderizados antes que os elementos de tela pai os componham.

Suponha que, no exemplo a seguir, o elemento nestedElement interno precise mudar visualmente (por exemplo, a cor de fundo do CSS muda quando o usuário mantém o ponteiro sobre ele). Primeiro, o evento paint é acionado na tela nestedCanvas. O listener limpa o buffer filho e chama ctx.drawElementImage(nestedElement, ...) para renderizar o estado atualizado. Em seguida, o evento paint é disparado em parentCanvas. O listener dele desenha wrapperElement (que contém a tela nestedCanvas já atualizada) na cena.

<!-- 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>
Quando os elementos <canvas content="drawable"> são aninhados, os eventos de pintura são acionados na ordem inversa da árvore: a tela aninhada é pintada primeiro para que a tela mãe possa desenhar os pixels atualizados.

Acionar um evento paint

Chame requestPaint() para programar um único evento paint no próximo frame, mesmo que nenhum texto ou estilo do DOM tenha mudado. Isso pode ser usado para renderizar cenas interativas com elementos em movimento, por exemplo:

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

Atualizar a geometria de um elemento

Quando você desenha um elemento HTML em uma tela, o código de desenho decide onde o elemento aparece e qual é o tamanho dele. Ao contrário do conteúdo HTML normal, a posição e o tamanho do elemento na tela, ou seja, a geometria dele, não podem ser determinados apenas pelo estilo computado. O navegador precisa da geometria de um elemento para fazer o seguinte:

  • Envie eventos de entrada, como cliques, toques e movimentos do ponteiro, para o elemento certo. Isso é chamado de teste de hit.
  • Retornar resultados precisos de consultas de layout, como getBoundingClientRect().
  • Informar os limites do elemento para tecnologias adaptativas, como lupas de tela.

Em 2D, o drawElementImage() atualiza a geometria automaticamente por padrão. Os argumentos transmitidos, junto com o estilo calculado do elemento e a transformação atual do contexto, são suficientes para determinar onde o elemento aparece.

Em contextos WebGL e WebGPU, o navegador não consegue determinar a geometria. Um elemento HTML é copiado para uma textura, e o programa de sombreador pode mapear essa textura na cena de qualquer maneira arbitrária. Para informar ao navegador onde o elemento aparece, transmita uma transformação para updateElementGeometry().

Quando o código para de chamar updateElementGeometry(), o navegador não sabe mais onde estão os elementos HTML no card 3D. O card continua girando, mas os cliques ainda vão para onde ele estava.

Para atualizar a geometria, transmita uma transformação espacial para o método. Saiba como criar uma transformação para diferentes contextos:

Gerenciar a ordem de empilhamento do teste de hit

Por padrão, sempre que a geometria de um elemento é atualizada, o navegador o coloca no topo da pilha de interação (último desenhado = destino de clique mais alto). É possível controlar esse comportamento usando preserveHitTestOrder:

// Updates the backdrop without stealing clicks from foreground elements
canvas.updateElementGeometry(backgroundElement, {
  canvasTransform: customTransform,
  preserveHitTestOrder: true
});
Opção Comportamento Caso de uso
preserveHitTestOrder: false (padrão) Move o elemento para o topo da pilha de teste de hit. Elementos em primeiro plano que devem receber cliques primeiro.
preserveHitTestOrder: true Deixa o elemento na prioridade de pilha atual. Elementos de segundo plano que precisam ser atualizados após os elementos de primeiro plano.

Redefinir geometria

Para desfazer o desenho de um elemento sem removê-lo do DOM, chame canvas.clearElementGeometry(). Isso redefine a transformação de tela do elemento e o marca como fora da tela na árvore de acessibilidade:

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

Dimensionamento e unidades

Ao desenhar HTML em uma tela, o navegador opera em três sistemas de coordenadas ao mesmo tempo: pixels CSS, pixels do dispositivo e pixels da grade da tela.

Os pixels CSS são usados para medir e posicionar o conteúdo durante o layout. Em seguida, o navegador os dimensiona por devicePixelRatio para rasterizar a página em pixels do dispositivo físico. Como os pixels CSS são uma unidade lógica, as posições e os tamanhos geralmente acabam como frações de um pixel do dispositivo (por exemplo, 10,5 px CSS × uma DPR de 1,5 = 15,75 px do dispositivo). Uma tela só pode colorir pixels inteiros, então o navegador precisa resolver tudo o que fica entre eles. Isso é feito de duas maneiras:

  • Ajuste: os limites da caixa, as bordas e os deslocamentos de camada são arredondados para o pixel de dispositivo inteiro mais próximo para que fiquem nítidos.
  • Suavização: para glifos de texto, curvas e bordas transformadas, a cobertura parcial é combinada com os pixels vizinhos para que eles ainda pareçam suaves e no lugar certo.

Um <canvas> tem a própria grade de pixels, definida pelos atributos width e height, que é separada dos pixels CSS e dos pixels do dispositivo. Para mostrá-lo, o navegador faz uma nova amostragem do bitmap da tela na grade de pixels físicos, combinando pixels vizinhos para preencher cada pixel do dispositivo. Para saber como o navegador mapeia entre CSS, tela e pixels do dispositivo, consulte Renderização perfeita de pixels com devicePixelContentBox.

Todos os elementos HTML drawable são dispostos em pixels CSS. Como o conteúdo desenhado em uma tela pode passar por duas conversões, de pixels CSS para a grade da tela e, em seguida, da grade da tela para pixels físicos, cada etapa que não se alinha exatamente adiciona outra rodada de mesclagem, e o resultado pode parecer desfocado. Para evitar isso, faça com que a grade da tela corresponda à grade de pixels físicos. Isso tem dois propósitos:

  • Renderização consistente: se o canvas for mapeado 1:1 em pixels físicos, a conversão de pixels CSS em pixels de canvas vai produzir o mesmo resultado que a conversão do navegador de pixels CSS em pixels físicos para HTML comum.
  • Sem mesclagem extra: se a tela for mapeada em uma proporção de 1:1 em pixels físicos, a conversão para pixels do dispositivo não vai adicionar desfoque.

A imagem a seguir demonstra uma conversão de pixel que resulta em conteúdo desfocado. As três grades não se alinham: o elemento canvas tem 360 × 180 pixels CSS, e o tamanho da tela é de 300 × 150 pixels de grade de tela. Com uma proporção de pixels do dispositivo de 1,5, a tela cobre 540 × 270 pixels do dispositivo. Um pixel CSS abrange 1,5 pixel do dispositivo, e um pixel de tela abrange 1,2 pixel CSS ou 1,8 pixel do dispositivo (painel 1). Quando o círculo é desenhado, a posição e o raio dele estão em pixels de tela, não em pixels CSS. Por isso, o navegador o desenha no bitmap de tela de 300 × 150 e suaviza a borda (painel 2). Para exibir o canvas, o navegador estica esse bitmap em 540 × 270 pixels do dispositivo. Por isso, ele precisa interpolar: cada pixel do dispositivo recebe uma combinação ponderada dos pixels mais próximos do canvas, que já contêm a borda suavizada. A borda é mesclada uma segunda vez e se espalha por vários pixels do dispositivo, de modo que o círculo fica desfocado (painel 3). Isso poderia ser evitado se a grade da tela correspondesse aos pixels do dispositivo na proporção de 1:1. O navegador suavizaria a borda apenas uma vez, diretamente na grade do dispositivo, e exibiria o bitmap sem reamostragem, para que a borda permanecesse nítida (painel 4):

Diagrama de quatro painéis comparando grades de pixels para uma borda curva: 1. Grades desalinhadas de CSS (360 por 180), tela (300 por 150) e dispositivo (540 por 270). 2. A curva com suavização aplicada à grade de tela de 300 por 150. 3. Esse bitmap é interpolado na grade do dispositivo de 540 por 270, suavizando o serrilhado uma segunda vez e desfocando a borda. 4. Uma tela de 540 por 270 que corresponde à grade do dispositivo de 1 para 1, com suavização de bordas para uma borda nítida.
Como grades de CSS, tela e pixels de dispositivo desalinhadas causam suavização dupla (painéis 1 a 3), em comparação com uma grade de tela para dispositivo de 1:1 (painel 4).

Para uma renderização nítida, a grade da tela precisa corresponder ao número de pixels do dispositivo que a tela cobre: 540 × 270 no exemplo anterior.

Comparação lado a lado de um card HTML com a mensagem &quot;Hello from the DOM!&quot; Este cartão é um HTML desenhado em uma tela.' À esquerda: desenhado na grade de tela padrão de 300 por 150, com aparência desfocada. À direita: desenhado em uma grade de tela de 800 por 400 dimensionada com ResizeObserver, com uma aparência nítida.
A mesma tela, com 400 x 200 pixels CSS, em uma tela com DPR de 1,5. À esquerda: a grade de tela de 400 x 200. À direita: ajustada para a DPR, uma grade de 600 x 300.

Dimensionar a grade do canvas com um observador de redimensionamento

O tamanho certo da grade depende do dispositivo e pode mudar quando o layout muda, quando o usuário aumenta o zoom na página ou quando move a janela para outra tela. Em vez de codificar o tamanho da grade, deixe o navegador informar qual deve ser o tamanho.

Para manter a grade da tela sincronizada com a tela, sempre use um ResizeObserver para dimensioná-la e ajuste a grade para 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);

Converter pixels CSS em coordenadas de grade de tela

Um manipulador paint geralmente combina medições do DOM com chamadas de desenho, por exemplo, para posicionar um elemento com base no tamanho dele. Os dois usam unidades diferentes:

  • Os pixels da tela descrevem a grade da tela, que é dimensionada por canvas.width e canvas.height. Por exemplo, os parâmetros dx, dy, dwidth e dheight dos métodos de desenho drawElementImage() ou outros parâmetros de APIs de tela, como fillRect() e translate().
  • Os pixels CSS descrevem a página. As medições do DOM, como offsetWidth e clientWidth, e as coordenadas de eventos de ponteiro, como offsetX, estão em pixels CSS.

Confira uma visão geral das unidades usadas por vários parâmetros da API:

Valor Unidades
canvas.width e canvas.height Pixels da grade da tela
Parâmetros de desenho 2D, incluindo dx, dy, dwidth e dheight de drawElementImage() Pixels da grade da tela
width e height retornados por canvas.getElementImageDefaultSize() Pixels da grade da tela
Retângulos de origem: sx, sy, swidth e sheight de drawElementImage() e texElementSubImage2D(); sourceX, sourceY, sourceWidth e sourceHeight de drawElementImageToTexture() Pixels CSS
Tamanhos de textura: opções width e height de texElementSubImage2D(); size de drawElementImageToTexture() Pixels de textura. Por padrão, o tamanho do elemento em pixels da grade da tela, arredondado para cima.
Geometria do elemento: getElementTransform() e as opções canvasTransform e canvasClip de updateElementGeometry() Pixels CSS
Medições de DOM, como offsetWidth, clientWidth, getBoundingClientRect(), coordenadas de eventos de ponteiro e contentBoxSize de uma entrada de observador de redimensionamento Pixels CSS
devicePixelContentBoxSize de uma entrada de observador de redimensionamento Pixels do dispositivo

Se você usar as unidades erradas, o tamanho do conteúdo HTML desenhado na tela pode não atender às suas expectativas. Você precisa converter o valor de pixel CSS em pixels de grade de tela ao desenhar HTML na tela. Para fazer isso, calcule a proporção entre o tamanho da grade da tela e o tamanho do CSS da tela: canvas.width / cssContentWidth na horizontal e canvas.height / cssContentHeight na vertical. O observador de redimensionamento é um lugar conveniente para calcular a proporção da seguinte maneira:

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

Em seguida, multiplique os valores de pixel CSS por scaleX e scaleY antes de transmiti-los ao método de desenho. Por exemplo, para centralizar um elemento drawable na tela:

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 fim, como drawElementImage() desenha a caixa de borda do elemento, use box-sizing: border-box para incluir padding e borda em width e height do card, para que ele seja desenhado no tamanho definido:

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

Entender a acessibilidade da HTML-in-Canvas

Em geral, o conteúdo HTML de um <canvas content="drawable"> é exposto à acessibilidade da mesma forma que o DOM comum, o que significa que ele é representado na árvore de acessibilidade, a menos que seja explicitamente ocultado usando atributos como aria-hidden="true" ou inert. No entanto, há uma exceção importante: um elemento drawable com geometria renderizada que ainda não foi estabelecida ou atualizada.

Um elemento drawable dentro de um <canvas content="drawable"> não terá geometria renderizada se:

  • Ele ainda não foi renderizado com drawElementImage(), que atualiza a geometria automaticamente por padrão.
  • A geometria não foi atualizada explicitamente com updateElementGeometry().

Se um elemento drawable não tiver uma geometria renderizada, esse elemento drawable e a subárvore drawable dele serão informados à árvore de acessibilidade sem informações de geometria. Isso é útil, por exemplo, para permitir que os leitores de tela acessem o conteúdo semântico, enquanto sinalizam ferramentas de assistência visual (como lupas) para filtrar os elementos não desenhados.

O exemplo a seguir preserva a semântica de uma figura, imagem e legenda ao desenhar a imagem e a figura separadamente:

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

Estratégias de fallback

A API HTML-in-Canvas foi lançada no Chrome 157 e pode não ser compatível com todos os navegadores. Para garantir que sua solução funcione sem problemas para todos, verifique se o navegador é compatível com a API e use uma solução alternativa, se necessário:

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

Se a API não for compatível, dependendo das suas metas de implementação, escolha uma das seguintes alternativas:

Próximas etapas