HTML-in-Canvas : concepts techniques

L'API HTML-in-Canvas vous permet d'afficher des sous-arborescences DOM interactives et accessibles directement dans des contextes de canevas 2D, WebGL ou WebGPU.

Ce guide décrit les concepts techniques du HTML dans Canvas qui sont communs à tous les contextes de canevas :

Préparer votre canevas pour l'affichage du code HTML

Pour afficher des éléments HTML dans un <canvas>, configurez les deux attributs suivants :

  • Définissez content="drawable" sur l'élément <canvas> : cela permet à l'élément canvas de disposer ses nœuds DOM descendants au lieu de les traiter uniquement comme du contenu masqué.
  • Définissez drawable sur les éléments cibles : cela marque un élément comme une racine de peinture indépendante éligible au rendu de canevas.
<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>

Comprendre la sous-arborescence de drawables

Lorsque vous préparez le code HTML à afficher dans un canevas, vous devez décider quels éléments doivent être dessinés indépendamment. Chaque élément drawable représente une racine de peinture indépendante et son sous-arbre drawable.

Une sous-arborescence drawable se compose d'un élément avec l'attribut drawable et de tous ses descendants, à l'exclusion des descendants imbriqués qui portent également drawable, par exemple :

<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>
Élément Peindre une limite Comportement
#parentElement (sous-arborescence A) Panneau d'arrière-plan, titre et paragraphe Affiché lors de l'appel de drawElementImage(parentElement, ...)
#nestedChildElement (sous-arbre B) Surface et texte du bouton Exclu de la sous-arborescence #parentElement ; affiché dans un appel drawElementImage(nestedChildElement, ...) distinct
Un bouton drawable est imbriqué dans une fiche drawable, et chacun est dessiné par son propre appel drawElementImage().

L'imbrication d'éléments dessinables vous permet de conserver les relations HTML sémantiques tout en vous offrant la liberté de superposer, de positionner ou d'animer des éléments enfants indépendamment sur votre scène de canevas.

Comprendre l'événement paint

L'API HTML-in-Canvas introduit l'événement paint. Le navigateur distribue automatiquement un événement paint sur l'élément <canvas> chaque fois que la mise en page, le texte ou les styles CSS changent sur l'un de ses descendants 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);
});

L'événement paint distribue une instance de CanvasPaintEvent, qui étend le Event DOM standard et possède une propriété changedElements qui ne contient que les descendants drawable qui ont été modifiés visuellement (par exemple, des modifications de style, des décalages de mise en page, des modifications de texte ou des changements d'état de sélection) depuis le dernier paint.

canvas.addEventListener("paint", (event) => {
  // Array of changed elements
  for (const el of event.changedElements) {
    console.log(`Element #${el.id} visually changed and needs repainting`);
  }
});
Le navigateur déclenche l'affichage sur le canevas chaque fois que le rendu d'un descendant drawable change.

Cycle de vie de la frame

Les commandes de dessin de canevas exécutées dans l'événement paint apparaissent dans la mise à jour de rendu actuelle, mais les modifications du DOM effectuées dans l'événement paint ne s'affichent pas avant l'image suivante :

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

Canevas imbriqués

Si votre page contient des éléments <canvas content="drawable"> imbriqués, le navigateur distribue les événements paint dans l'ordre inverse de l'arborescence DOM (les descendants se déclenchent avant les ancêtres). Cela garantit que les éléments de canevas enfants sont affichés avant que les éléments de canevas parents ne les composent.

Supposons que, dans l'exemple suivant, l'élément nestedElement interne doive changer visuellement (par exemple, sa couleur d'arrière-plan CSS change lorsque l'utilisateur pointe dessus). Tout d'abord, l'événement paint se déclenche sur le canevas nestedCanvas. Son écouteur efface le tampon enfant et appelle ctx.drawElementImage(nestedElement, ...) pour afficher l'état mis à jour. Ensuite, l'événement paint se déclenche sur parentCanvas. Son écouteur dessine wrapperElement (qui contient le canevas nestedCanvas déjà mis à jour) dans la scène.

<!-- 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>
Lorsque des éléments <canvas content="drawable"> sont imbriqués, les événements de peinture se déclenchent dans l'ordre inverse de l'arborescence : le canevas imbriqué est peint en premier, de sorte que le canevas parent puisse dessiner ses pixels à jour.

Déclencher un événement paint

Appelez requestPaint() pour planifier un seul événement paint sur l'image suivante, même si aucun texte ni style DOM n'a changé. Cela peut être utilisé pour afficher des scènes interactives avec des éléments en mouvement, par exemple :

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

Mettre à jour la géométrie d'un élément

Lorsque vous dessinez un élément HTML dans un canevas, votre code de dessin détermine l'emplacement et la taille de l'élément. Contrairement au contenu HTML standard, la position et la taille de l'élément à l'écran, sa géométrie, ne peuvent pas être déterminées uniquement à partir de son style calculé. Le navigateur a besoin de la géométrie d'un élément pour effectuer les opérations suivantes :

  • Envoyer des événements d'entrée tels que des clics, des appuis et des mouvements de pointeur vers l'élément approprié. C'est ce qu'on appelle le test de positionnement.
  • Renvoyer des résultats précis pour les requêtes de mise en page, telles que getBoundingClientRect().
  • Signalez les limites de l'élément aux technologies d'assistance, telles que les loupes d'écran.

En 2D, drawElementImage() met à jour la géométrie automatiquement par défaut. Les arguments que vous lui transmettez, ainsi que le style calculé de l'élément et la transformation actuelle du contexte, suffisent à déterminer l'emplacement de l'élément.

Dans les contextes WebGL et WebGPU, le navigateur ne peut pas déterminer la géométrie. Un élément HTML est copié dans une texture, et votre programme de nuanceur peut ensuite mapper cette texture sur la scène de n'importe quelle manière. Pour indiquer au navigateur où l'élément apparaît, transmettez une transformation à updateElementGeometry().

Lorsque le code cesse d'appeler updateElementGeometry(), le navigateur ne sait plus où se trouvent les éléments HTML sur la carte 3D. La carte continue de tourner, mais les clics redirigent toujours vers l'emplacement d'origine.

Pour mettre à jour la géométrie, transmettez une transformation spatiale à la méthode. Découvrez comment créer une transformation pour différents contextes :

Gérer l'ordre d'empilement des tests de positionnement

Par défaut, chaque fois que la géométrie d'un élément est mise à jour, le navigateur le place en haut de la pile d'interaction (dernier dessiné = cible de clic la plus haute). Vous pouvez contrôler ce comportement à l'aide de preserveHitTestOrder :

// Updates the backdrop without stealing clicks from foreground elements
canvas.updateElementGeometry(backgroundElement, {
  canvasTransform: customTransform,
  preserveHitTestOrder: true
});
Option Comportement Cas d'utilisation
preserveHitTestOrder: false (par défaut) Déplace l'élément en haut de la pile de test de positionnement. Éléments de premier plan qui doivent recevoir des clics en premier.
preserveHitTestOrder: true Laisse l'élément à sa priorité d'empilement actuelle. Éléments d'arrière-plan qui doivent être mis à jour après les éléments de premier plan.

Réinitialiser la géométrie

Pour annuler le dessin d'un élément sans le supprimer du DOM, appelez canvas.clearElementGeometry(). Cela réinitialise la transformation du canevas de l'élément et le marque comme hors écran dans l'arborescence d'accessibilité :

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

Dimensionnement et unités

Lorsque vous dessinez du code HTML dans un canevas, le navigateur fonctionne simultanément dans trois systèmes de coordonnées : les pixels CSS, les pixels de l'appareil et les pixels de la grille du canevas.

Les pixels CSS sont utilisés pour mesurer et positionner le contenu lors de la mise en page. Le navigateur les met ensuite à l'échelle par devicePixelRatio pour pixéliser la page en pixels d'appareil physique. Comme les pixels CSS sont une unité logique, les positions et les tailles se retrouvent souvent sous forme de fractions d'un pixel d'appareil (par exemple, 10,5 px CSS × un DPR de 1,5 = 15,75 px d'appareil). Un écran ne peut colorer que des pixels entiers. Le navigateur doit donc résoudre tout ce qui se trouve entre eux. Pour ce faire, il utilise deux méthodes :

  • Alignement : les limites des cadres, les bordures et les décalages de calques sont arrondis au pixel d'appareil entier le plus proche pour rester nets.
  • Antialiasing : pour les glyphes de texte, les courbes et les bords transformés, la couverture partielle est fusionnée avec les pixels voisins afin qu'ils restent lisses et au bon endroit.

Un <canvas> possède sa propre grille de pixels, définie par ses attributs width et height, qui est distincte des pixels CSS et des pixels de l'appareil. Pour l'afficher, le navigateur rééchantillonne le bitmap du canevas sur la grille de pixels physiques, en mélangeant les pixels du canevas voisins pour remplir chaque pixel de l'appareil. Pour savoir comment le navigateur mappe les pixels CSS, de canevas et de l'appareil, consultez Rendu au pixel près avec devicePixelContentBox.

Tous les éléments HTML drawable sont disposés en pixels CSS. Étant donné que le contenu dessiné dans un canevas peut passer par deux conversions (des pixels CSS à la grille du canevas, puis de la grille du canevas aux pixels physiques), chaque étape qui ne s'aligne pas exactement ajoute un autre cycle de fusion, et le résultat peut sembler flou. Pour éviter cela, faites correspondre la grille du canevas à la grille de pixels physiques. Cela a deux objectifs :

  • Rendu cohérent : si le canevas est mappé 1:1 sur des pixels physiques, la conversion des pixels CSS en pixels de canevas produit le même résultat que la conversion des pixels CSS en pixels physiques par le navigateur pour le code HTML standard.
  • Pas de mélange supplémentaire : si le canevas est mappé 1:1 sur des pixels physiques, sa conversion en pixels de l'appareil n'ajoute pas de flou.

L'image suivante illustre une conversion de pixels qui entraîne un contenu flou. Les trois grilles ne sont pas alignées : l'élément canevas fait 360 x 180 pixels CSS, et la taille du canevas est de 300 x 150 pixels de grille de canevas. Avec un rapport de pixels de l'appareil de 1,5, le canevas couvre 540 × 270 pixels de l'appareil. Un pixel CSS s'étend sur 1,5 pixel d'appareil, et un pixel de canevas s'étend sur 1,2 pixel CSS, soit 1,8 pixel d'appareil (panneau 1). Lorsque le cercle est dessiné, sa position et son rayon sont exprimés en pixels de canevas, et non en pixels CSS. Le navigateur le dessine donc dans le bitmap de canevas de 300 x 150 et applique un lissage au bord (panneau 2). Pour afficher le canevas, le navigateur étire ensuite ce bitmap sur 540 × 270 pixels de l'appareil. Il doit donc interpoler : chaque pixel de l'appareil prend un mélange pondéré des pixels du canevas les plus proches, qui contiennent déjà le bord avec lissage de contours. Le bord est fusionné une deuxième fois et s'étend sur plusieurs pixels de l'appareil. Le cercle apparaît donc flou (panneau 3). Cela pourrait être évité si la grille du canevas correspondait aux pixels de l'appareil selon un rapport de 1:1. Le navigateur applique ensuite l'anticrénelage au bord une seule fois, directement sur la grille de l'appareil, et affiche le bitmap sans rééchantillonnage, de sorte que le bord reste net (panneau 4) :

Diagramme à quatre panneaux comparant les grilles de pixels pour un bord incurvé : 1. Grilles CSS (360 x 180), canevas (300 x 150) et appareil (540 x 270) non alignées. 2. La courbe est lissée sur la grille de canevas grossière de 300 par 150. 3. Ce bitmap est interpolé sur la grille de l'appareil de 540 x 270, ce qui entraîne un deuxième anticrénelage et un floutage du bord. 4. Un canevas de 540 x 270 correspondant à la grille de l'appareil 1:1, avec un anticrénelage pour un bord net.
Comment des grilles de pixels CSS, de canevas et d'appareil non alignées provoquent un double anticrénelage (panneaux 1 à 3), par rapport à une grille de canevas à appareil 1:1 (panneau 4).

Pour un rendu net, la grille du canevas doit correspondre au nombre de pixels de l'appareil couverts par le canevas : 540 × 270 dans l'exemple précédent.

Comparaison côte à côte d'une fiche HTML indiquant &quot;Hello from the DOM! Cette carte est du code HTML dessiné dans un canevas.' À gauche : dessiné sur la grille de canevas par défaut de 300 x 150, l'image est floue. À droite : dessiné sur une grille de canevas de 800 x 400 pixels dimensionnée avec ResizeObserver, l'image est nette.
Le même canevas, 400 x 200 pixels CSS, sur un écran avec un DPR de 1,5. À gauche : la grille de canevas de 400 x 200. À droite : ajustée au DPR, une grille de 600 x 300.

Dimensionner la grille du canevas avec un observateur de redimensionnement

La taille de grille appropriée dépend de l'appareil et peut changer lorsque la mise en page change, lorsque l'utilisateur zoome sur la page ou lorsqu'il déplace la fenêtre vers un autre écran. Ainsi, au lieu de coder en dur la taille de la grille, laissez le navigateur vous indiquer ce qu'elle doit être.

Pour que la grille du canevas reste synchronisée avec le canevas à l'écran, dimensionnez-la toujours avec un ResizeObserver et ajustez la grille du canevas sur 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);

Convertir des pixels CSS en coordonnées de grille de canevas

Un gestionnaire paint combine souvent des mesures DOM avec des appels de dessin, par exemple pour positionner un élément en fonction de sa taille. Les deux utilisent des unités différentes :

  • Les pixels de canevas décrivent la grille du canevas, dont la taille est définie par canvas.width et canvas.height. Par exemple, les paramètres dx, dy, dwidth et dheight des méthodes de dessin drawElementImage(), ou d'autres paramètres d'API de canevas, tels que fillRect() et translate().
  • Les pixels CSS décrivent la page. Les mesures DOM telles que offsetWidth et clientWidth, ainsi que les coordonnées des événements de pointeur telles que offsetX, sont exprimées en pixels CSS.

Voici un aperçu des unités utilisées par les différents paramètres de l'API :

Valeur Unités
canvas.width et canvas.height Pixels de la grille du canevas
Paramètres de dessin 2D, y compris dx, dy, dwidth et dheight de drawElementImage() Pixels de la grille du canevas
width et height renvoyés par canvas.getElementImageDefaultSize() Pixels de la grille du canevas
Rectangles sources : sx, sy, swidth et sheight de drawElementImage() et texElementSubImage2D() ; sourceX, sourceY, sourceWidth et sourceHeight de drawElementImageToTexture() Pixels CSS
Tailles de texture : options width et height de texElementSubImage2D() ; size de drawElementImageToTexture() Pixels de texture. Par défaut, la taille de l'élément en pixels de la grille du canevas, arrondie à l'entier supérieur
Géométrie de l'élément : getElementTransform(), et options canvasTransform et canvasClip de updateElementGeometry() Pixels CSS
Mesures DOM, telles que offsetWidth, clientWidth, getBoundingClientRect(), les coordonnées d'événement de pointeur et contentBoxSize d'une entrée d'observateur de redimensionnement Pixels CSS
devicePixelContentBoxSize d'une entrée ResizeObserver Pixels de l'appareil

Si vous utilisez des unités incorrectes, la taille du contenu HTML dessiné dans le canevas risque de ne pas correspondre à vos attentes. Vous devez convertir la valeur en pixels CSS en pixels de grille de canevas lorsque vous dessinez du code HTML dans un canevas. Pour ce faire, calculez le ratio entre la taille de la grille du canevas et la taille CSS du canevas : canvas.width / cssContentWidth horizontalement et canvas.height / cssContentHeight verticalement. Votre observateur de redimensionnement est un endroit pratique pour calculer le ratio comme suit :

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

Ensuite, multipliez les valeurs de pixels CSS par scaleX et scaleY avant de les transmettre à la méthode de dessin. Par exemple, pour centrer un élément drawable dans le canevas :

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

Enfin, étant donné que drawElementImage() dessine la zone de bordure de l'élément, utilisez box-sizing: border-box pour inclure la marge intérieure et la bordure dans les propriétés width et height de la carte, afin que celle-ci soit dessinée à la taille que vous avez définie :

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

Comprendre l'accessibilité du HTML dans Canvas

En général, le contenu HTML d'un <canvas content="drawable"> est exposé à l'accessibilité de la même manière que le DOM standard, ce qui signifie qu'il est représenté dans l'arborescence d'accessibilité, sauf s'il est explicitement masqué à l'aide d'attributs tels que aria-hidden="true" ou inert. Toutefois, il existe une exception importante : un élément drawable dont la géométrie affichée n'a pas encore été établie ou mise à jour.

Un élément drawable à l'intérieur d'un <canvas content="drawable"> n'a pas de géométrie affichée si :

  • Il n'a pas encore été affiché avec drawElementImage(), qui met à jour la géométrie automatiquement par défaut.
  • Sa géométrie n'a pas été explicitement mise à jour avec updateElementGeometry().

Si un élément drawable ne comporte pas de géométrie affichée, cet élément drawable et sa sous-arborescence drawable sont signalés à l'arborescence d'accessibilité sans informations géométriques. Cela est utile, par exemple, pour permettre aux lecteurs d'écran d'accéder au contenu sémantique, tout en signalant aux outils d'assistance visuelle (comme les loupes) de filtrer les éléments non dessinés.

L'exemple suivant préserve la sémantique d'une figure, d'une image et d'une légende tout en dessinant l'image et la figure séparément :

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

Stratégies de remplacement

L'API HTML-in-Canvas est disponible dans Chrome 157 et n'est peut-être pas compatible avec tous les navigateurs. Pour vous assurer que votre solution fonctionne correctement pour tous les utilisateurs, vérifiez si le navigateur est compatible avec l'API et utilisez une autre solution si nécessaire :

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

Si l'API n'est pas compatible, vous pouvez choisir l'une des alternatives suivantes en fonction de vos objectifs d'implémentation :

Étapes suivantes