Chi ha costruito una scena WebGL con dentro un modulo conosce il dilemma. O disegni l’interfaccia a mano nel canvas, e perdi la selezione del testo, l’accessibilità e l’andata a capo, oppure metti l’HTML sopra il canvas e passi il tempo a ricalcolare le posizioni perché non si stacchi dalla scena. Dal 26 ottobre c’è una terza strada: Chrome 157 include HTML-in-Canvas, l’API che disegna HTML vivo dentro un <canvas> 2D, WebGL o WebGPU.
La differenza rispetto a quello che c’era finora è che il browser non scatta una foto dell’HTML: continua a trattarlo come HTML. Il testo si può selezionare e copiare, lo screen reader lo legge, Ctrl+F lo trova, il traduttore lo traduce e la compilazione automatica riempie i suoi campi, anche quando appare deformato su una mesh 3D. La proposta è nel WICG dall’agosto 2024. Chrome l’ha avuta prima dietro un flag e, dalla versione 148, in origin trial, e lungo la strada ha cambiato nomi più volte; più sotto ci sono le corrispondenze, nel caso abbiate provato una versione precedente.
Il problema di sempre: DOM o pixel
Un testo disegnato con ctx.fillText() sono pixel. Non va a capo, non conosce il grassetto né il testo da destra a sinistra, non si può selezionare e, per uno screen reader, non esiste: bisogna mantenere a mano un DOM parallelo e invisibile che dica le stesse cose. E per un’internazionalizzazione seria tocca caricare un motore di impaginazione del testo in JavaScript.
Le vie d’uscita abituali avevano il loro prezzo:
- Trasformare l’HTML in un’immagine con librerie come html2canvas o dom-to-image. Nel canvas arriva una foto: il pulsante non è più un pulsante. E siccome la libreria reimplementa il CSS per conto suo, quello che non supporta semplicemente non appare.
- Far galleggiare l’HTML sopra il canvas. Funziona finché la telecamera non si muove: DOM e pixel vanno con tempi diversi e il pannello trema o resta indietro. In più l’HTML sta sempre sopra a tutto, quindi non può nascondersi dietro un oggetto 3D, vedersi attraverso un vetro né riflettersi in uno specchio. E a ogni fotogramma bisogna scrivere
style.transformsu ogni pannello, con il ricalcolo degli stili che ne consegue.

Come funziona: attributi, evento paint e una chiamata per disegnare
L’API si regge su pochissimi pezzi. Il primo è dichiarativo: il <canvas> riceve content="drawable", che dice al browser di impaginare i suoi figli invece di trattarli come contenuto di riserva, e ogni elemento da disegnare riceve l’attributo drawable. L’HTML vive dentro il canvas, impaginato ma invisibile finché non lo disegni.
Il secondo è l’evento paint. Il browser lo lancia sul canvas ogni volta che cambia l’aspetto di un elemento drawable: uno stile, il testo di un campo, il focus o un hover. Dentro il gestore si disegna. Basta questo per un esempio completo in 2D:
<canvas id="tela" width="600" height="400" content="drawable">
<div id="scheda" drawable>
<h2>Dettagli del prodotto</h2>
<p>7,60 €</p>
<label>Email <input type="email"></label>
<button>Acquista</button>
</div>
</canvas>
<script>
const canvas = document.getElementById('tela');
const ctx = canvas.getContext('2d');
const scheda = document.getElementById('scheda');
canvas.addEventListener('paint', () => {
ctx.reset();
ctx.rotate(-0.1);
ctx.drawElementImage(scheda, 40, 80);
});
</script>
La scheda appare ruotata dentro il canvas eppure il campo email accetta la digitazione, il pulsante riceve il clic nel punto in cui si vede e il testo si può selezionare. In 2D il browser sincronizza da solo la geometria: sa dove hai disegnato l’elemento e porta lì i clic e i riquadri del focus.

Alcuni dettagli da sapere fin dall’inizio:
drawElementImage()accetta le stesse varianti didrawImage(): posizione; posizione e dimensioni; ritaglio della sorgente; ritaglio più destinazione (3, 5, 7 e 9 argomenti).- I
drawablesi possono annidare. Un pulsantedrawabledentro una schedadrawableresta fuori dal disegno della scheda e si disegna con una chiamata propria, così si può animare separatamente senza rompere la semantica dell’HTML. - L’evento porta
changedElements, gli elementi cambiati dall’ultimo disegno, per non dover ridisegnare tutto. - Le modifiche al DOM fatte dentro
paintsi vedono nel fotogramma successivo. Se qualcosa deve apparire in quello attuale, va cambiato inrequestAnimationFrame, che viene eseguito prima. - Per le animazioni continue,
canvas.requestPaint()chiede un altropaintnel fotogramma successivo anche se non è cambiato nulla, comerequestAnimationFrame().
flowchart TD
A["Cambia l'HTML: stile, testo, focus"] --> B["Evento paint sul canvas"]
B --> C["2D: drawElementImage"]
B --> D["WebGL: texElementSubImage2D"]
B --> E["WebGPU: drawElementImageToTexture"]
D --> F["updateElementGeometry"]
E --> F
C --> G["Clic, focus e accessibilità dove è stato disegnato"]
F --> GWebGL: l’HTML come texture
In 3D l’idea è la stessa, ma l’elemento non si disegna nel canvas: si carica in una texture. Prima si riserva la memoria della texture una sola volta, passando null come dati, e poi, a ogni paint, si copia l’HTML con texElementSubImage2D():
const pannello = document.getElementById('pannello'); // l'elemento drawable
const gl = canvas.getContext('webgl2');
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
// Riserva la memoria una volta (o quando cambiano le dimensioni dell'elemento)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 640, 400, 0,
gl.RGBA, gl.UNSIGNED_BYTE, null);
canvas.addEventListener('paint', () => {
// 1. L'HTML nella texture
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texElementSubImage2D(gl.TEXTURE_2D, 0, 0, 0, pannello);
// 2. La scena, con la texture sulla geometria che preferisci
disegnaScena();
// 3. Dove è finito il pannello, in pixel CSS del canvas
const canvasTransform = new DOMMatrix().translate(x, y).scale(scala);
canvas.updateElementGeometry(pannello, { canvasTransform });
});
Il terzo passo è quello che si dimentica più spesso. In 2D il browser sa dove è finito l’elemento; in WebGL no, perché lo shader può mettere quella texture ovunque. Con updateElementGeometry() glielo si dice, così clic, focus e strumenti di accessibilità puntano al posto giusto. La documentazione di Chrome lo mostra con una scheda 3D che continua a ruotare quando il codice smette di chiamare questo metodo: i clic continuano a cadere dove si trovava prima. Per gli elementi di sfondo c’è l’opzione preserveHitTestOrder: true, che evita che l’ultimo aggiornato passi davanti a tutti nel ricevere i clic.

Una volta sulla GPU, quella texture è una come le altre: si può curvare su una mesh, illuminare, riflettere o passare in uno shader. L’immagine di copertina di questo articolo è lo stesso modulo ondulato da un vertex shader e ombreggiato lungo la curva.
In WebGPU la chiamata è drawElementImageToTexture(), sulla coda del dispositivo, e la geometria si sincronizza allo stesso modo, con updateElementGeometry():
canvas.addEventListener('paint', () => {
device.queue.drawElementImageToTexture(
{ source: pannello },
{ texture: texture }
);
disegnaScena();
canvas.updateElementGeometry(pannello, { canvasTransform });
});
Cosa conserva l’HTML dentro il canvas
Secondo Chrome, il contenuto disegnato così mantiene:
- L’impaginazione completa del testo: più righe, testo bidirezionale e CSS.
- Selezione, copia e incolla, e menu contestuale, anche su una superficie 3D.
- L’albero di accessibilità, per gli screen reader.
- Trova nella pagina, traduzione e compilazione automatica, anche dentro texture WebGL e WebGPU.
- Le estensioni: una che sostituisce testi cambia anche quello che si vede sulla mesh 3D.
- L’ispezione e la modifica degli stili in DevTools.
- L’indicizzazione da parte di crawler e agenti di IA, che vedono DOM invece di un’immagine.
- L’esportazione dell’intero canvas con
toDataURL(),toBlob()o, in video,captureStream().
Sull’accessibilità c’è una sfumatura. Un elemento drawable non ancora disegnato, o la cui geometria non è stata aggiornata, arriva all’albero di accessibilità senza posizione: lo screen reader può leggerlo, ma una lente d’ingrandimento sa che non è sullo schermo. Se lasciate nel canvas viste che non disegnate più, conviene toglierle dal DOM o marcarle con aria-hidden="true" o inert.
Quello che il browser non disegna
C’è una restrizione che non compare negli esempi e che può riservare sorprese. Siccome il contenuto di un canvas si può leggere pixel per pixel (con getImageData() o da WebGL), l’API non disegna nulla che permetta di spiare l’utente. Secondo la proposta del WICG restano fuori:
- I contenuti di un’altra origine: immagini, iframe, sfondi CSS con
url(), SVG esterni o canvas «contaminati». Un iframe della stessa origine si disegna, ma non quello che contiene da altre origini. - Colori, tema e preferenze di sistema.
- I segni del correttore ortografico e grammaticale.
- Se un link è già stato visitato.
- I dati di compilazione automatica non ancora confermati.
- L’antialiasing subpixel del testo, i sottotitoli e i controlli video e le finestre del metodo di input (IME).
In pratica: se la vostra scheda mostra un’immagine servita da una CDN su un altro dominio, nel canvas non apparirà. Le evidenziazioni di Trova nella pagina, il cursore del testo e l’aspetto dei controlli dei moduli invece si disegnano.
Pixel nitidi: il dettaglio che si dimentica sempre
Se il testo esce sfocato, quasi sempre è perché la griglia del canvas non coincide con i pixel fisici dello schermo. L’HTML si impagina in pixel CSS, il canvas ha la sua griglia (width e height) e lo schermo ha i suoi pixel reali; ogni conversione che non torna ammorbidisce il testo un’altra volta. Chrome consiglia di dimensionare il canvas con un ResizeObserver su device-pixel-content-box:
let scalaX = 1, scalaY = 1; // pixel del canvas per pixel CSS
const observer = new ResizeObserver(([entry]) => {
const dpc = entry.devicePixelContentBoxSize;
const { width, height } = entry.contentRect;
canvas.width = dpc ? dpc[0].inlineSize : Math.round(width * devicePixelRatio);
canvas.height = dpc ? dpc[0].blockSize : Math.round(height * devicePixelRatio);
scalaX = canvas.width / width;
scalaY = canvas.height / height;
});
observer.observe(canvas, { box: 'device-pixel-content-box' });
Attenzione alle unità: le coordinate di destinazione di drawElementImage() sono in pixel del canvas, mentre offsetWidth, getBoundingClientRect(), gli eventi del puntatore e il canvasTransform di updateElementGeometry() sono in pixel CSS. Per centrare un elemento bisogna moltiplicare le sue misure per scalaX e scalaY. E siccome si disegna il border box dell’elemento, box-sizing: border-box evita che il padding lo renda più grande del previsto.
Se l’avete provato durante l’origin trial, controllate il codice
Chrome 150 e Chrome 155 hanno cambiato l’API in base ai commenti degli sviluppatori e dei gruppi di standardizzazione. Il codice scritto per le prime versioni non funziona così com’è:
| Prima | Adesso (Chrome 155+) |
|---|---|
<canvas layoutsubtree> | <canvas content="drawable"> e drawable su ogni elemento |
| Si disegnavano solo i figli diretti del canvas | Qualsiasi discendente con drawable, annidabili |
drawElementImage() restituiva una matrice da copiare in style.transform | Non restituisce nulla: in 2D si sincronizza da solo (salvo con preserveElementGeometry: true) |
WebGL: texElementImage2D() | texElementSubImage2D(), con offset e memoria riservata prima con texImage2D(…, null) |
WebGPU: copyElementImageToTexture() | drawElementImageToTexture(), con sourceX, sourceWidth… e size |
Il blog di Chrome ha pubblicato frammenti per convivere con più versioni insieme, per esempio controllando 'texElementSubImage2D' in WebGL2RenderingContext.prototype prima di scegliere la chiamata.
Rilevare il supporto e avere un piano B
Per ora è una cosa di Chrome. Mozilla ha aperta la richiesta di posizione sulla proposta dal settembre 2024 e WebKit dal marzo 2026, entrambe senza un verdetto. Qualsiasi uso in produzione richiede il rilevamento delle funzionalità. Quello proposto da Chrome:
export function supportsHtmlInCanvas() {
if (typeof document === 'undefined') return false;
const canvas = document.createElement('canvas');
canvas.setAttribute('content', 'drawable');
return canvas.content === 'drawable';
}
Se restituisce false, le alternative sono quelle di sempre: un’interfaccia diversa, HTML che galleggia sopra il canvas, la libreria three-html-render, html2canvas o dom-to-image. Nessuna di queste librerie è di Google: sono progetti della comunità.
Three.js, Babylon.js, PixiJS e PlayCanvas
Scrivere a mano le matrici di ogni pannello stanca presto, e i motori si sono già mossi:
- Three.js: supporto sperimentale con
THREE.HTMLTexture. - Babylon.js: la classe
BABYLON.HtmlTexture. - PixiJS:
HTMLSourcedalla versione 8.19.0. - PlayCanvas: texture alimentate dall’HTML con
setSource(). - CanvasUI: una libreria open source di componenti pensata per HTML-in-Canvas.
Con Three.js, mettere un elemento HTML sulle facce di un cubo è così:
const material = new THREE.MeshBasicMaterial();
material.map = new THREE.HTMLTexture(pannelloHtml);
const cubo = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), material);
scene.add(cubo);
Il grado di maturità varia da un motore all’altro; prima di puntare su uno, conviene guardare la sua documentazione.
A cosa serve (e a cosa no)
Dove si noterà di più è nelle applicazioni che vivono già dentro un canvas: editor grafici, lavagne, fogli di calcolo o strumenti di design che oggi disegnano da soli i propri menu e campi di testo. Lì possono usare HTML vero, guadagnare accessibilità e liberarsi del codice che imitava quello che il browser sa già fare. Anche nei giochi e in WebXR, con terminali dentro il gioco o libri da leggere nella scena; nei grafici accelerati dalla GPU con legende ed etichette su più righe; nei configuratori di prodotto con prezzo e opzioni attaccati al modello 3D; e negli strumenti creativi che mescolano tipografia HTML e shader ed esportano il risultato in immagine o video.
Per un sito normale non aggiunge nulla. Un blog, un negozio o un pannello di amministrazione stanno meglio nel DOM di sempre, che è più semplice, più veloce da mantenere e funziona in tutti i browser. HTML-in-Canvas ha senso quando c’è già un canvas e finora l’HTML doveva restarne fuori.
Come abbiamo fatto le immagini
Le immagini di questo articolo non sono fotomontaggi: sono catture di due piccole demo che abbiamo scritto per provare l’API. Le abbiamo fatte con Chrome 156, che ha già l’implementazione dietro il flag chrome://flags/#canvas-draw-element (o avviando il browser con --enable-blink-features=CanvasDrawElement); in Chrome 157 non serve niente. La demo WebGL non usa nessun motore, solo WebGL2: una mesh a griglia di 64×64 con il modulo come texture, il riflesso disegnato specchiando la geometria e uno shader a onda per la copertina. Quello che ci ha sorpreso di più è stato vedere la selezione del testo del campo di ricerca viaggiare con la texture, piegarsi con l’onda e comparire nel riflesso sul pavimento senza scrivere una sola riga per farlo.
Fonti
- HTML-in-Canvas in Chrome 157: Interactive DOM inside 2D, WebGL, and WebGPU (Chrome for Developers, 26 ottobre 2026)
- Documentazione di HTML-in-Canvas: concetti, 2D, WebGL e WebGPU
- Modifiche dell’API in Chrome 150 e 155
- Proposta del WICG, con la sezione sulla privacy e demo dal vivo
- Raccolta di demo della comunità
- Posizioni di Mozilla e WebKit (aperte al 30 ottobre 2026)