HTML-in-Canvas : Rendez de Vrais Éléments DOM dans un Canvas avec la Nouvelle API drawElement

Noqta Team
Par Noqta Team ·

Chargement du lecteur de synthèse vocale...

La plateforme web vient de gagner un superpouvoir. La proposition WICG HTML-in-Canvas — désormais derrière un flag dans Chromium — vous permet de rendre de vrais éléments HTML interactifs directement dans un <canvas>. Formulaires, texte, tableaux de bord, widgets UI complets — dessinés dans des contextes 2D ou WebGL tout en conservant l'accessibilité complète du DOM.

Fini les hacks html2canvas. Fini les contournements foreignObject SVG. Du rendu HTML natif, de première classe, à l'intérieur du canvas.

Dans ce tutoriel, vous allez construire 4 démos qui montrent ce que cette API rend possible — et comprendre l'architecture qui la sous-tend.

Prérequis

  • Node.js 18+
  • Next.js 14+ (App Router)
  • Chrome Canary avec chrome://flags/#canvas-draw-element activé
  • Connaissances de base de l'API Canvas

Le Problème Résolu

Jusqu'à présent, rendre du contenu HTML dans un canvas nécessitait des solutions de contournement pénibles :

ApprocheLimitation
html2canvasRe-rend le DOM en bitmap — pas d'interactivité, rendu imprécis
foreignObject en SVGCanvas contaminé, problèmes CORS, pas de support WebGL
Dessin Canvas manuelVous perdez tout le CSS, l'accessibilité, l'i18n, la mise en page du texte
Texte WebGLNécessite des atlas de polices, pas de mises en page complexes, surcharge massive

L'API HTML-in-Canvas résout tout cela en laissant le moteur de rendu du navigateur dessiner les éléments DOM dans un contexte canvas.

L'API : Trois Primitives

1. L'attribut layoutsubtree

<canvas layoutsubtree width="800" height="600">
  <!-- Ces enfants sont de VRAIS éléments DOM -->
  <div class="card">
    <h2>Je suis un vrai titre</h2>
    <button>Je suis un vrai bouton</button>
  </div>
</canvas>

L'attribut layoutsubtree dit au navigateur : « Dispose mes enfants comme des éléments DOM normaux, mais rends-les aussi disponibles pour le dessin dans le canvas. » Les enfants obtiennent :

  • Un contexte d'empilement
  • Un comportement de bloc conteneur
  • Un confinement de peinture
  • Un hit testing complet et l'accessibilité

2. drawElement() pour le Canvas 2D

const ctx = canvas.getContext('2d');
const transform = ctx.drawElement(childElement, x, y, { width, height });
childElement.style.transform = transform;

Cela dessine l'élément enfant — incluant tous ses styles CSS, pseudo-éléments, ombres et mise en page — dans le canvas à la position (x, y). La transformation retournée aligne la position DOM avec la position dessinée pour que les événements fonctionnent correctement.

3. texElement2D() pour WebGL

const gl = canvas.getContext('webgl2');
gl.texElement2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, childElement);

Cela télécharge le rendu de l'élément comme texture WebGL — permettant des transformations 3D, de l'éclairage, du post-traitement et des effets de shader sur du vrai contenu HTML.

Configuration

npx create-next-app@latest html-in-canvas-demo --typescript --tailwind --app
cd html-in-canvas-demo

Démo 1 : Dessiner du HTML dans un Canvas (Basique)

Le cas d'utilisation le plus simple — prendre un élément HTML et le dessiner dans un canvas 2D.

// app/basic/page.tsx
'use client';
 
import { useEffect, useRef } from 'react';
 
export default function BasicDemo() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const cardRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    const canvas = canvasRef.current;
    const card = cardRef.current;
    if (!canvas || !card) return;
 
    const ctx = canvas.getContext('2d')!;
    canvas.width = 800;
    canvas.height = 500;
 
    // L'événement paint se déclenche quand les enfants changent
    canvas.addEventListener('paint', () => {
      ctx.clearRect(0, 0, 800, 500);
 
      // Dessiner le fond sombre
      ctx.fillStyle = '#0f172a';
      ctx.fillRect(0, 0, 800, 500);
 
      // Dessiner l'élément HTML card à la position (200, 100)
      // drawElement rend le sous-arbre DOM stylé COMPLET
      const transform = ctx.drawElement(card, 200, 100, {
        width: 400,
        height: 300,
      });
 
      // Appliquer la transformation pour que les événements souris touchent le bon endroit
      card.style.transform = transform;
    });
  }, []);
 
  return (
    <canvas ref={canvasRef} layoutsubtree width={800} height={500}>
      {/* Ceci est un VRAI élément DOM à l'intérieur du canvas */}
      <div
        ref={cardRef}
        className="rounded-xl bg-slate-800 p-8 text-white shadow-2xl"
      >
        <h2 className="text-2xl font-bold mb-4">Bonjour depuis le DOM</h2>
        <p className="text-slate-300 mb-6">
          Cette carte est un vrai élément HTML — avec un style CSS complet,
          l'accessibilité et la gestion des événements — dessinée dans un canvas.
        </p>
        <button
          className="rounded-lg bg-sky-500 px-6 py-2 font-semibold
                     hover:bg-sky-400 transition-colors"
          onClick={() => alert('Bouton cliqué ! Les événements fonctionnent.')}
        >
          Cliquez-moi
        </button>
      </div>
    </canvas>
  );
}

Ce qui se passe :

  1. L'attribut layoutsubtree dit à Chrome de disposer le <div> comme un élément DOM normal
  2. ctx.drawElement() le rend dans le canvas à (200, 100) avec les dimensions spécifiées
  3. La transform retournée est appliquée à l'élément pour que les événements de clic correspondent à la bonne position
  4. L'événement paint se déclenche chaque fois que l'élément enfant change — pas de polling manuel nécessaire

Démo 2 : Formulaire Interactif dans le Canvas

De vrais formulaires — avec états de focus, navigation au clavier et validation — à l'intérieur d'un canvas.

// app/form/page.tsx
'use client';
 
import { useEffect, useRef, useState } from 'react';
 
export default function FormDemo() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const formRef = useRef<HTMLFormElement>(null);
  const [submitted, setSubmitted] = useState(false);
 
  useEffect(() => {
    const canvas = canvasRef.current;
    const form = formRef.current;
    if (!canvas || !form) return;
 
    const ctx = canvas.getContext('2d')!;
    canvas.width = 900;
    canvas.height = 600;
 
    canvas.addEventListener('paint', () => {
      ctx.clearRect(0, 0, 900, 600);
 
      // Dégradé de fond
      const grad = ctx.createLinearGradient(0, 0, 900, 600);
      grad.addColorStop(0, '#0f172a');
      grad.addColorStop(1, '#1e1b4b');
      ctx.fillStyle = grad;
      ctx.fillRect(0, 0, 900, 600);
 
      // Cercles décoratifs
      ctx.beginPath();
      ctx.arc(700, 100, 200, 0, Math.PI * 2);
      ctx.fillStyle = '#38bdf820';
      ctx.fill();
 
      ctx.beginPath();
      ctx.arc(200, 500, 150, 0, Math.PI * 2);
      ctx.fillStyle = '#a78bfa15';
      ctx.fill();
 
      // Dessiner le formulaire au centre
      const transform = ctx.drawElement(form, 250, 80, {
        width: 400,
        height: 440,
      });
      form.style.transform = transform;
    });
  }, []);
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    setSubmitted(true);
    setTimeout(() => setSubmitted(false), 2000);
  };
 
  return (
    <canvas ref={canvasRef} layoutsubtree width={900} height={600}>
      <form
        ref={formRef}
        onSubmit={handleSubmit}
        className="rounded-2xl bg-slate-800/90 backdrop-blur p-8 shadow-2xl
                   border border-slate-700"
      >
        <h2 className="text-xl font-bold text-white mb-6">Contactez-nous</h2>
 
        <label className="block mb-4">
          <span className="text-sm text-slate-400">Nom</span>
          <input
            type="text"
            required
            className="mt-1 w-full rounded-lg bg-slate-900 border border-slate-600
                       px-4 py-2.5 text-white focus:border-sky-400
                       focus:ring-2 focus:ring-sky-400/30 outline-none"
            placeholder="Votre nom"
          />
        </label>
 
        <label className="block mb-4">
          <span className="text-sm text-slate-400">Email</span>
          <input
            type="email"
            required
            className="mt-1 w-full rounded-lg bg-slate-900 border border-slate-600
                       px-4 py-2.5 text-white focus:border-sky-400
                       focus:ring-2 focus:ring-sky-400/30 outline-none"
            placeholder="vous@exemple.com"
          />
        </label>
 
        <label className="block mb-6">
          <span className="text-sm text-slate-400">Message</span>
          <textarea
            rows={4}
            className="mt-1 w-full rounded-lg bg-slate-900 border border-slate-600
                       px-4 py-2.5 text-white focus:border-sky-400
                       focus:ring-2 focus:ring-sky-400/30 outline-none resize-none"
            placeholder="Comment pouvons-nous vous aider ?"
          />
        </label>
 
        <button
          type="submit"
          className={`w-full rounded-lg py-3 font-semibold transition-all ${
            submitted
              ? 'bg-green-500 text-white'
              : 'bg-sky-500 text-white hover:bg-sky-400'
          }`}
        >
          {submitted ? 'Envoyé !' : 'Envoyer le message'}
        </button>
      </form>
    </canvas>
  );
}

Pourquoi c'est important : Le formulaire est un vrai élément DOM. Les lecteurs d'écran le voient. La touche Tab fonctionne. Le remplissage automatique du navigateur fonctionne. Les gestionnaires de mots de passe fonctionnent. Le tout rendu dans un canvas avec des fonds décoratifs personnalisés qui seraient impossibles avec du DOM pur.

Démo 3 : Widgets de Tableau de Bord dans le Canvas

Composez plusieurs widgets HTML dans un seul canvas — avec un compositing accéléré par GPU.

// app/dashboard/page.tsx
'use client';
 
import { useEffect, useRef } from 'react';
 
function StatCard({ title, value, change, color }: {
  title: string; value: string; change: string; color: string;
}) {
  return (
    <div className="rounded-xl bg-slate-800 p-5 border border-slate-700">
      <p className="text-sm text-slate-400">{title}</p>
      <p className="text-2xl font-bold text-white mt-1">{value}</p>
      <p className={`text-sm font-medium mt-2`} style={{ color }}>
        {change}
      </p>
    </div>
  );
}
 
export default function DashboardDemo() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const card1Ref = useRef<HTMLDivElement>(null);
  const card2Ref = useRef<HTMLDivElement>(null);
  const card3Ref = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const ctx = canvas.getContext('2d')!;
    canvas.width = 900;
    canvas.height = 500;
 
    const cards = [card1Ref, card2Ref, card3Ref];
 
    canvas.addEventListener('paint', () => {
      ctx.clearRect(0, 0, 900, 500);
      ctx.fillStyle = '#0f172a';
      ctx.fillRect(0, 0, 900, 500);
 
      // Titre dessiné avec l'API Canvas
      ctx.fillStyle = '#f8fafc';
      ctx.font = 'bold 24px Inter, system-ui, sans-serif';
      ctx.fillText('Tableau de Bord Analytique', 30, 40);
 
      // Dessiner chaque carte stat à différentes positions
      const positions = [
        { x: 30, y: 70 },
        { x: 310, y: 70 },
        { x: 590, y: 70 },
      ];
 
      cards.forEach((ref, i) => {
        if (ref.current) {
          const t = ctx.drawElement(ref.current, positions[i].x, positions[i].y, {
            width: 250,
            height: 120,
          });
          ref.current.style.transform = t;
        }
      });
 
      // Dessiner un graphique avec l'API Canvas sous les cartes HTML
      drawChart(ctx, 30, 220, 840, 250);
    });
  }, []);
 
  return (
    <canvas ref={canvasRef} layoutsubtree width={900} height={500}>
      <div ref={card1Ref}>
        <StatCard title="Revenus" value="$12,847" change="+12.5%" color="#4ade80" />
      </div>
      <div ref={card2Ref}>
        <StatCard title="Utilisateurs" value="3,429" change="+8.2%" color="#38bdf8" />
      </div>
      <div ref={card3Ref}>
        <StatCard title="Commandes" value="842" change="+23.1%" color="#a78bfa" />
      </div>
    </canvas>
  );
}
 
function drawChart(
  ctx: CanvasRenderingContext2D,
  x: number, y: number, w: number, h: number
) {
  // Fond du graphique
  ctx.fillStyle = '#1e293b';
  ctx.beginPath();
  ctx.roundRect(x, y, w, h, 12);
  ctx.fill();
 
  // Ligne du graphique
  const points = [40, 65, 45, 80, 60, 90, 75, 95, 70, 100, 85, 110];
  ctx.beginPath();
  ctx.strokeStyle = '#38bdf8';
  ctx.lineWidth = 2.5;
 
  points.forEach((p, i) => {
    const px = x + 30 + (i / (points.length - 1)) * (w - 60);
    const py = y + h - 30 - (p / 120) * (h - 60);
    if (i === 0) ctx.moveTo(px, py);
    else ctx.lineTo(px, py);
  });
  ctx.stroke();
 
  // Remplissage en dégradé
  const lastPx = x + 30 + ((points.length - 1) / (points.length - 1)) * (w - 60);
  ctx.lineTo(lastPx, y + h - 30);
  ctx.lineTo(x + 30, y + h - 30);
  ctx.closePath();
  const grad = ctx.createLinearGradient(0, y, 0, y + h);
  grad.addColorStop(0, '#38bdf830');
  grad.addColorStop(1, '#38bdf805');
  ctx.fillStyle = grad;
  ctx.fill();
 
  ctx.fillStyle = '#f8fafc';
  ctx.font = 'bold 14px Inter, system-ui';
  ctx.fillText('Revenus au Fil du Temps', x + 16, y + 28);
}

La puissance ici : Les cartes de statistiques HTML vous donnent un style CSS complet, des états de survol et l'accessibilité — tandis que le graphique en dessous est dessiné avec l'API Canvas pour un rendu au pixel près. L'événement paint garde tout synchronisé. Une seule surface de rendu, le meilleur des deux mondes.

Démo 4 : HTML comme Texture WebGL (Carte 3D)

La primitive la plus excitante — téléchargez un élément HTML comme texture WebGL et appliquez des transformations 3D.

// app/3d-card/page.tsx
'use client';
 
import { useEffect, useRef } from 'react';
 
export default function ThreeDCardDemo() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const profileRef = useRef<HTMLDivElement>(null);
 
  useEffect(() => {
    const canvas = canvasRef.current;
    const profile = profileRef.current;
    if (!canvas || !profile) return;
 
    const gl = canvas.getContext('webgl2')!;
    canvas.width = 800;
    canvas.height = 600;
 
    // Créer une texture à partir de l'élément HTML
    const texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texture);
 
    // Télécharger l'élément HTML comme texture
    // texElement2D rend la sortie stylée complète de l'élément
    gl.texElement2D(
      gl.TEXTURE_2D,    // cible
      0,                 // niveau
      gl.RGBA,           // format interne
      gl.RGBA,           // format
      gl.UNSIGNED_BYTE,  // type
      profile            // l'élément HTML
    );
 
    // Paramètres de texture
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
 
    // Maintenant utilisez cette texture dans votre scène WebGL
    // Appliquez des transformations de perspective, éclairage, réflexions...
    // Le contenu HTML devient un objet 3D de première classe
    renderScene(gl, texture);
  }, []);
 
  return (
    <canvas ref={canvasRef} layoutsubtree width={800} height={600}>
      <div
        ref={profileRef}
        className="w-72 rounded-2xl bg-gradient-to-b from-slate-800 to-slate-900
                   p-8 text-center border border-slate-700 shadow-2xl"
      >
        <div className="w-20 h-20 mx-auto rounded-full bg-gradient-to-br
                        from-sky-400 to-violet-500 flex items-center
                        justify-center text-3xl font-bold text-white mb-4">
          N
        </div>
        <h3 className="text-xl font-bold text-white">Noqta Agent</h3>
        <p className="text-sky-400 text-sm mt-1">Agence de Développement IA</p>
        <div className="flex justify-around mt-6 text-center">
          <div>
            <p className="text-lg font-bold text-white">127</p>
            <p className="text-xs text-slate-400">Projets</p>
          </div>
          <div>
            <p className="text-lg font-bold text-white">48</p>
            <p className="text-xs text-slate-400">Clients</p>
          </div>
          <div>
            <p className="text-lg font-bold text-white">2.4k</p>
            <p className="text-xs text-slate-400">Étoiles</p>
          </div>
        </div>
        <button className="mt-6 w-full rounded-lg bg-gradient-to-r
                           from-sky-500 to-violet-500 py-2.5 font-semibold
                           text-white hover:opacity-90 transition">
          Voir le Profil
        </button>
      </div>
    </canvas>
  );
}
 
function renderScene(gl: WebGL2RenderingContext, texture: WebGLTexture | null) {
  // Vertex shader pour une carte rotative
  const vsSource = `#version 300 es
    in vec4 aPosition;
    in vec2 aTexCoord;
    uniform mat4 uProjection;
    uniform mat4 uModelView;
    out vec2 vTexCoord;
    void main() {
      gl_Position = uProjection * uModelView * aPosition;
      vTexCoord = aTexCoord;
    }
  `;
 
  // Fragment shader — applique la texture HTML
  const fsSource = `#version 300 es
    precision highp float;
    in vec2 vTexCoord;
    uniform sampler2D uTexture;
    out vec4 fragColor;
    void main() {
      fragColor = texture(uTexture, vTexCoord);
    }
  `;
 
  // ... configuration WebGL standard : compiler les shaders, créer le programme,
  // configurer la géométrie de la carte, animer la rotation avec requestAnimationFrame
  // L'idée clé : la texture EST la sortie rendue de l'élément HTML
}

Ce que texElement2D permet :

  • Des cartes produit 3D avec du vrai contenu HTML
  • Des effets de texte en perspective avec une typographie CSS complète
  • Des transitions basées sur les shaders entre vues HTML
  • Des interfaces de réalité mixte avec du HTML dans des scènes WebXR

Comment Fonctionne l'Événement paint

L'événement paint est le mécanisme de synchronisation. Il se déclenche quand un enfant du canvas change — re-rendus, animations, changements de style.

canvas.addEventListener('paint', () => {
  // Un instantané du rendu de tous les enfants est capturé
  // AVANT que cet événement ne se déclenche. Quand vous appelez
  // drawElement() ici, il utilise cet instantané — pas de passe
  // de rendu supplémentaire.
 
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.drawElement(myElement, 0, 0);
});

Cela signifie :

  • Pas de double rendu — le navigateur capture les pixels des enfants dans la même passe de compositing
  • Pas de requestAnimationFrame manuel — l'événement paint se déclenche au bon moment
  • Regroupement automatique — plusieurs changements d'enfants déclenchent un seul événement paint

Support Navigateur (Avril 2026)

NavigateurStatut
Chrome CanaryDerrière le flag #canvas-draw-element
Chrome StableAttendu Q3 2026
FirefoxEn cours de considération
SafariAucun signal pour le moment

Pour une utilisation en production aujourd'hui, vous aurez besoin d'une stratégie d'amélioration progressive :

function CanvasWithFallback({ children }: { children: React.ReactNode }) {
  const supportsDrawElement = typeof HTMLCanvasElement !== 'undefined'
    && 'drawElement' in CanvasRenderingContext2D.prototype;
 
  if (!supportsDrawElement) {
    // Rendre comme DOM normal
    return <div className="canvas-fallback">{children}</div>;
  }
 
  return (
    <canvas layoutsubtree width={800} height={600}>
      {children}
    </canvas>
  );
}

Ce Que Vous Avez Construit

DémoAPI UtiliséeCapacité
HTML dans CanvasdrawElement()Rendre n'importe quel HTML stylé dans un canvas 2D
Formulaire InteractifdrawElement() + événementsFormulaire complet avec focus, validation, a11y
Tableau de BorddrawElement() + API CanvasMixer widgets HTML avec graphiques canvas
Carte 3D ProfiltexElement2D()Contenu HTML comme texture WebGL avec transformations 3D

Pourquoi HTML-in-Canvas Change Tout

Avant cette API, canvas et DOM étaient deux mondes séparés. Vous pouviez soit :

  • Utiliser le DOM et renoncer aux performances/effets du canvas
  • Utiliser le canvas et renoncer à l'accessibilité, l'i18n, la mise en page du texte, les événements
  • Utiliser html2canvas et obtenir une capture d'écran cassée

HTML-in-Canvas comble le fossé. Le moteur de rendu du navigateur dessine les éléments DOM dans le canvas — avec une fidélité totale, une interactivité complète et un compositing accéléré par GPU.

Cela ouvre la voie à une nouvelle classe d'applications web :

  • Outils créatifs (type Figma/Canva) avec édition de texte réelle dans le canvas
  • Interfaces de jeux avec menus HTML accessibles superposés sur des scènes WebGL
  • Visualisation de données avec infobulles et étiquettes HTML dans les graphiques canvas
  • Outils de présentation avec transitions 3D entre diapositives HTML

Vous construisez une application intensive en canvas ? Nos agents livrent du Next.js en production avec Canvas avancé, WebGL et creative coding — 45$/h, avec supervision humaine. Réservez un appel gratuit

Et Ensuite ?

  • Éditeur type Figma avec édition de texte réelle dans les objets canvas
  • Éditeur photo avec panneaux de contrôle HTML rendus dans l'espace de travail canvas
  • Portfolio 3D avec cartes de profil HTML flottant dans une scène WebGL
  • Tableau de bord de données interactif mélangeant graphiques D3 et widgets HTML

L'API HTML-in-Canvas est le pont manquant entre DOM et canvas. Les jours où il fallait choisir l'un ou l'autre sont révolus.

Besoin d'aide pour construire un produit basé sur le canvas ? Des outils créatifs aux tableaux de bord de données, nos agents IA gèrent le code Canvas/WebGL complexe pendant que vous vous concentrez sur le produit. Parlez à un agent

Lectures Complémentaires


Canvas nous a donné les pixels. Le DOM nous a donné la structure. HTML-in-Canvas nous donne les deux — en même temps, dans le même élément.


Vous voulez lire plus de tutoriels? Découvrez notre dernier tutoriel sur Améliorer la communication GitLab avec les webhooks.

Discutez de votre projet avec nous

Nous sommes ici pour vous aider avec vos besoins en développement Web. Planifiez un appel pour discuter de votre projet et comment nous pouvons vous aider.

Trouvons les meilleures solutions pour vos besoins.

Articles connexes