jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

HTML in Canvas — Vì sao nó có thể thay đổi cách xây dựng web app đồ họa

Deep-dive vào WICG HTML-in-Canvas proposal: render HTML thật vào canvas/GPU texture, giữ nguyên accessibility và CSS. Kèm demo workflow editor thực tế với Canvas edges + HTML nodes.

Mở bài: Hai thế giới chưa bao giờ hoà hợp

Trong 20 năm lịch sử web, chúng ta sống với hai paradigm tách biệt:

HTML/DOM — tuyệt vời cho UI: text layout, accessibility, form controls, responsive design. Nhưng DOM chậm khi render hàng nghìn element, và không thể áp dụng GPU shader.

Canvas/WebGL — tuyệt vời cho graphics: vẽ hàng triệu pixel mỗi frame, shader effects, 3D transforms. Nhưng Canvas là “hố đen”: không có text selection, không có accessibility tree, không có browser extensions, không có find-in-page.

Mọi ứng dụng phức tạp — Figma, Miro, Excalidraw, workflow builders — đều phải chọn một trong hai, hoặc hack cả hai lại với nhau bằng overlay phức tạp.

HTML-in-Canvas là proposal của WICG muốn xoá bỏ ranh giới này.


HTML in Canvas là gì?

Định nghĩa

HTML-in-Canvas là một Web API proposal cho phép render trực tiếp HTML element vào canvas — không phải chụp screenshot, không phải rasterize offline, mà là render live, styled, accessible HTML thành canvas pixel hoặc GPU texture.

Ba primitive mới

APIMục đích
layoutsubtree attributeOpt-in element vào layout bên trong canvas
drawElementImage()Vẽ element vào 2D/WebGL/WebGPU context
paint eventFire khi element thay đổi, cho phép redraw hiệu quả
// API cơ bản — vẽ HTML element vào canvas
const canvas = document.querySelector('canvas');
const ctx = canvas.getContext('2d');
const myDiv = document.querySelector('.my-styled-div');

canvas.addEventListener('paint', () => {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  const transform = ctx.drawElementImage(myDiv, 50, 50);
  myDiv.style.transform = transform; // align DOM position for events
});

So sánh với html2canvas

html2canvasHTML-in-Canvas API
Cách hoạt độngClone DOM, re-render thủ côngNative browser rendering
Accuracy~80% (nhiều CSS không support)100% (browser tự render)
PerformanceChậm (parse + render lại toàn bộ)Nhanh (dùng render pipeline sẵn có)
Live updatesPhải gọi lại mỗi framePaint event tự động
AccessibilityMất hoàn toànGiữ nguyên DOM
GPU supportKhôngWebGL + WebGPU texture
Browser supportMọi browserChrome Canary (flag) — 2026

Điểm khác biệt cốt lõi: html2canvas “chụp ảnh” HTML. HTML-in-Canvas giữ HTML sống và render nó trực tiếp vào GPU.


Vì sao nó hot?

1. GPU Acceleration

HTML elements được render thành texture trên GPU. Nghĩa là bạn có thể:

  • Zoom/pan không giới hạn mà không re-layout DOM
  • Apply shader effects lên HTML content
  • Mix HTML với 3D geometry

2. Infinite Canvas Apps

Figma, Miro, tldraw — tất cả dùng canvas cho performance nhưng phải reimplementing text rendering, accessibility, form controls. HTML-in-Canvas cho phép dùng HTML thật trên infinite canvas.

3. Workflow Editors

AI agent canvas, n8n, Retool Workflows — mỗi node là một UI phức tạp (buttons, inputs, dropdowns). Hiện tại phải overlay HTML lên canvas. Tương lai có thể render trực tiếp vào canvas.

4. Accessibility + Performance

Element vẫn là DOM thật: screen readers hoạt động, find-in-page hoạt động, browser extensions hoạt động, autofill hoạt động. Nhưng rendering được GPU tăng tốc.


Use Case: Workflow Builder

Workflow builder là gì?

Một ứng dụng cho phép người dùng kết nối các “node” thành chuỗi xử lý. Mỗi node thực hiện một tác vụ: gọi API, biến đổi data, kiểm tra điều kiện, gửi email.

┌──────────┐    ┌──────────────┐    ┌─────────────┐    ┌────────────┐
│ Webhook  │───→│ Transform    │───→│ Check       │───→│ Send       │
│ Trigger  │    │ Data         │    │ Amount      │    │ Email      │
└──────────┘    └──────────────┘    └─────────────┘    └────────────┘

Kiến trúc hiện tại

Hầu hết workflow editors dùng HTML overlay pattern:

  • Canvas layer: vẽ connections/edges giữa các node
  • HTML layer: render các node dưới dạng DOM element phía trên canvas

Đây là cách Figma, React Flow, và hầu hết các thư viện flow/graph đều hoạt động.


Demo: Workflow Editor với Canvas + HTML

Tôi đã xây dựng một demo hoàn chỉnh minh hoạ kiến trúc HTML overlay. Bạn có thể xem live tại /tools/workflow-canvas-demo/.

API Design

const workflow = createWorkflow("#app");

workflow.addNode({
  id: "start",
  type: "trigger",
  title: "Webhook Trigger",
  x: 100,
  y: 120,
  fields: [
    { label: "Method", value: "POST" },
    { label: "Path", value: "/api/order" }
  ]
});

workflow.addNode({
  id: "transform",
  type: "action",
  title: "Transform Data",
  x: 420,
  y: 160,
  fields: [
    { label: "Input", value: "order.payload" }
  ]
});

workflow.connect("start", "transform");
workflow.render();

Phân tích kiến trúc

Vì sao node nên là HTML

  • Text rendering miễn phí: font shaping, ligatures, RTL, line wrapping
  • Interactivity miễn phí: click, hover, focus, input, dropdown đều hoạt động native
  • Accessibility miễn phí: screen reader, keyboard navigation, aria attributes
  • Styling miễn phí: CSS animations, transitions, responsive, theming

Vì sao edge nên là Canvas

  • Performance: hàng trăm bezier curves mỗi frame không vấn đề
  • Visual quality: anti-aliased curves, custom line styles, gradients
  • Hit testing: dễ kiểm tra point-on-curve hơn SVG hit testing phức tạp

Khi nào dùng SVG thay Canvas

Tiêu chíCanvasSVG
Số lượng edge> 100 → Canvas wins< 100 → SVG fine
AnimationrequestAnimationFrameCSS animations, SMIL
Interactivity trên edgeManual hit testingNative DOM events
Zoom qualityCần redrawVector, auto sharp
MemoryBitmap bufferDOM nodes per path

Quy tắc: dưới 100 elements → SVG đơn giản hơn. Trên 100 hoặc cần custom rendering → Canvas.

Khi nào cần WebGL/WebGPU

  • Hàng nghìn nodes + edges đồng thời
  • 3D effects trên nodes (flip, rotate, perspective)
  • Custom shader effects (glow, blur, distortion)
  • Particle systems cho visual feedback

Trade-offs giữa DOM, SVG, Canvas

DOM (HTML)SVGCanvas 2DWebGL/WebGPU
Performance với nhiều elementChậm > 1000Chậm > 500Tốt > 10KTốt > 100K
Text renderingNative, perfectGood, nhưng flow layout yếuManual, khóManual, rất khó
AccessibilityFullPartial (aria)KhôngKhông
InteractivityNative eventsNative eventsManual hit testingManual hit testing
GPU accelerationLimited (transform/opacity)Limited2D context (CPU)Full GPU
Zoom/PanCSS transform (reflow risk)viewBox (vector)Manual redrawMatrix transforms

Liên hệ với HTML-in-Canvas tương lai

Hiện tại: HTML Overlay Pattern

┌────────────────────────────────────────┐
│  HTML Layer (position: absolute)       │  ← Nodes (DOM elements)
│  ┌─────┐  ┌─────┐  ┌─────┐           │
│  │Node │  │Node │  │Node │           │
│  └─────┘  └─────┘  └─────┘           │
├────────────────────────────────────────┤
│  Canvas Layer                          │  ← Edges (bezier curves)
│  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~  │
└────────────────────────────────────────┘

Vấn đề:

  • Zoom/pan phải sync cả DOM và Canvas
  • DOM reflow khi di chuyển node
  • Không thể apply shader lên toàn bộ scene
  • Z-index management phức tạp

Tương lai: HTML-in-Canvas Native

┌────────────────────────────────────────┐
│  Canvas (single rendering context)     │
│                                        │
│  ┌─────┐  ┌─────┐  ┌─────┐           │  ← Nodes drawn via
│  │Node │  │Node │  │Node │           │     drawElementImage()
│  └─────┘  └─────┘  └─────┘           │
│  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~  │  ← Edges drawn via
│                                        │     bezier paths
└────────────────────────────────────────┘
│  DOM still exists (accessibility)      │  ← Hidden but accessible

Lợi ích:

  • Single rendering context: mọi thứ trên cùng một canvas, không sync issue
  • GPU shader cho toàn scene: blur background khi zoom out, glow effect khi node active
  • Zoom/pan native: chỉ cần thay đổi canvas transform matrix
  • Accessibility giữ nguyên: DOM vẫn tồn tại cho screen readers
// Tương lai: render node HTML trực tiếp vào WebGPU texture
// {Future: render node HTML directly into WebGPU texture}
canvas.addEventListener('paint', () => {
  for (const node of nodes) {
    const transform = ctx.drawElementImage(node.element, node.x, node.y);
    node.element.style.transform = transform;
  }
  drawEdges(ctx, connections);
});

Trạng thái hiện tại (2026)

AspectStatus
SpecWICG proposal, được review tại Google I/O 2026
ChromeCanary (Chromium 147+) với flag canvas-draw-element
FirefoxNegative signal (concerns về fingerprinting)
SafariChưa có signal
Origin TrialM148–M151 (khoảng 3 tháng)
Production ready?Chưa — chỉ nên experiment

Cách thử nghiệm

  1. Cài Chrome Canary (149+)
  2. Vào chrome://flags/#canvas-draw-element → Enable
  3. Restart browser
  4. Clone github.com/WICG/html-in-canvas để xem examples

Mở rộng thành Production App

Demo hiện tại là proof-of-concept. Để xây production workflow editor, cần thêm:

Architecture layers

┌─────────────────────────────────────┐
│  UI Layer (React/Vue/Svelte)        │  Components, state management
├─────────────────────────────────────┤
│  Graph Engine                       │  Node/edge data model, layout algo
├─────────────────────────────────────┤
│  Renderer                           │  Canvas/SVG/WebGL abstraction
├─────────────────────────────────────┤
│  Interaction Manager                │  Drag, zoom, pan, selection
├─────────────────────────────────────┤
│  Execution Engine                   │  Run the workflow, handle async
└─────────────────────────────────────┘

Tính năng cần thiết

  • Undo/redo: command pattern trên graph state
  • Serialization: JSON export/import
  • Minimap: render toàn cảnh ở góc
  • Snap-to-grid: alignment guides
  • Group/ungroup: compound nodes
  • Keyboard shortcuts: copy, paste, delete, select all
  • Collaboration: CRDT hoặc OT cho multi-user

Thư viện hiện có

  • React Flow — React-based, HTML nodes + SVG edges
  • Rete.js — Framework-agnostic node editor
  • Litegraph.js — Canvas-based, Comfy UI dùng
  • tldraw — Infinite canvas library

Hướng dẫn sử dụng HTML-in-Canvas API

Setup cơ bản

<!-- Step 1: Canvas với layoutsubtree attribute -->
<canvas id="myCanvas" style="width: 600px; height: 400px;" layoutsubtree>
  <!-- Step 2: HTML content là con trực tiếp của canvas -->
  <div id="myContent" style="width: 200px; padding: 16px; background: #111; border: 1px solid #2a2a2a;">
    <h3 style="color: #c8ff00;">Hello from HTML</h3>
    <p>This is real DOM rendered into canvas.</p>
    <button>Click me — I still work!</button>
  </div>
</canvas>

Lưu ý quan trọng:

  • layoutsubtree attribute trên <canvas> là bắt buộc
  • Element phải là con trực tiếp của canvas
  • Element phải có display khác none

drawElementImage() — Hàm chính

const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');
const content = document.getElementById('myContent');

// Cách 1: Vẽ tại vị trí (x, y)
// {Method 1: Draw at position (x, y)}
canvas.onpaint = () => {
  ctx.reset(); // Clear canvas
  const transform = ctx.drawElementImage(content, 100, 50);

  // QUAN TRỌNG: sync DOM position để events hoạt động
  // {IMPORTANT: sync DOM position so events work}
  content.style.transform = transform.toString();
};
// Cách 2: Vẽ với kích thước tuỳ chỉnh
// {Method 2: Draw with custom size}
canvas.onpaint = () => {
  ctx.reset();
  // drawElementImage(element, x, y, width, height)
  const transform = ctx.drawElementImage(content, 50, 50, 300, 200);
  content.style.transform = transform.toString();
};
// Cách 3: Áp dụng canvas transforms (zoom, rotate)
// {Method 3: Apply canvas transforms (zoom, rotate)}
canvas.onpaint = () => {
  ctx.reset();

  // Zoom 2x và xoay 5 độ
  // {Zoom 2x and rotate 5 degrees}
  ctx.translate(300, 200);
  ctx.scale(2, 2);
  ctx.rotate(0.08);

  const transform = ctx.drawElementImage(content, -100, -50);
  content.style.transform = transform.toString();
};

Return value: drawElementImage() trả về một DOMMatrix — CSS transform cần áp dụng cho element để DOM position khớp với vị trí vẽ. Điều này đảm bảo click events, focus, accessibility hoạt động đúng.

paint event — Khi nào vẽ lại

// paint event tự động fire khi content thay đổi
// {paint event automatically fires when content changes}
canvas.addEventListener('paint', (event) => {
  ctx.reset();

  // event.changedElements chứa danh sách element đã thay đổi
  // {event.changedElements contains list of changed elements}
  console.log('Changed:', event.changedElements);

  // Vẽ lại tất cả elements
  // {Redraw all elements}
  for (const el of canvas.children) {
    if (el.nodeType === Node.ELEMENT_NODE) {
      const t = ctx.drawElementImage(el, getX(el), getY(el));
      el.style.transform = t.toString();
    }
  }
});

// Yêu cầu vẽ lại thủ công (như requestAnimationFrame)
// {Request manual redraw (like requestAnimationFrame)}
canvas.requestPaint();

Khi nào paint event fire:

  • Khi CSS của child element thay đổi (color, size, animation frame)
  • Khi nội dung text thay đổi
  • Khi form input value thay đổi
  • KHÔNG fire khi chỉ thay đổi transform

captureElementImage() — OffscreenCanvas

// Capture snapshot để dùng trong Worker
// {Capture snapshot for use in Worker}
canvas.onpaint = () => {
  const elementImage = canvas.captureElementImage(content);
  // Transfer sang worker
  worker.postMessage({ elementImage }, [elementImage]);
};

// Trong worker:
// {In worker:}
self.onmessage = (e) => {
  if (e.data.elementImage) {
    const t = offscreenCtx.drawElementImage(e.data.elementImage, 0, 0);
    self.postMessage({ transform: t });
  }
};

WebGL — HTML thành texture

const gl = canvas.getContext('webgl2');

canvas.onpaint = () => {
  // Vẽ HTML element thành WebGL texture
  // {Draw HTML element as WebGL texture}
  gl.texElementImage2D(
    gl.TEXTURE_2D,    // target
    0,                // level
    gl.RGBA,          // internalFormat
    gl.RGBA,          // format
    gl.UNSIGNED_BYTE, // type
    content           // HTML element
  );

  // Bây giờ texture chứa rendered HTML
  // {Now texture contains rendered HTML}
  // Có thể map lên 3D geometry, apply shader, etc.
};

WebGPU — HTML thành GPU texture

const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const gpuCtx = canvas.getContext('webgpu');

canvas.onpaint = () => {
  const texture = device.createTexture({
    size: [canvas.width, canvas.height],
    format: 'rgba8unorm',
    usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
  });

  // Copy HTML element vào GPU texture
  // {Copy HTML element into GPU texture}
  device.queue.copyElementImageToTexture(
    content,                          // source HTML element
    { texture: texture }              // destination GPU texture
  );
};

Sizing và DPI handling

// Canvas grid phải match device pixel ratio để không bị mờ
// {Canvas grid must match device pixel ratio to avoid blurriness}
const observer = new ResizeObserver(([entry]) => {
  canvas.width = entry.devicePixelContentBoxSize[0].inlineSize;
  canvas.height = entry.devicePixelContentBoxSize[0].blockSize;
});
observer.observe(canvas, { box: 'device-pixel-content-box' });

Lưu ý và giới hạn

Vấn đềChi tiết
CSS transform trên element bị bỏ qua khi vẽBrowser bỏ qua transform của element khi render vào canvas, nhưng transform vẫn ảnh hưởng hit testing
Overflow bị clipContent tràn khỏi border box bị cắt
Chỉ con trực tiếpdrawElementImage() chỉ chấp nhận con trực tiếp của canvas
Fingerprinting riskRender pixels có thể khác nhau giữa các browser/OS → fingerprint vector
Cross-origin restrictionTương tự canvas tainted — cross-origin content có thể bị chặn
Snapshot timingdrawElementImage() ngoài paint event dùng frame trước

Pattern: Multiple elements với transform

const nodes = [
  { el: document.getElementById('node1'), x: 100, y: 50 },
  { el: document.getElementById('node2'), x: 350, y: 150 },
  { el: document.getElementById('node3'), x: 600, y: 80 },
];

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

  // Apply global zoom/pan
  ctx.translate(panX, panY);
  ctx.scale(zoom, zoom);

  // Draw each node
  for (const node of nodes) {
    const t = ctx.drawElementImage(node.el, node.x, node.y);
    node.el.style.transform = t.toString();
  }

  // Draw edges (plain canvas API)
  drawBezierEdges(ctx, connections, nodes);
};

Kết luận

HTML-in-Canvas không phải silver bullet. Nó không thay thế DOM cho web app thông thường, và không thay thế Canvas/WebGL cho game.

Nhưng nó mở ra mô hình kết hợp mới: ứng dụng có thể vừa giữ semantic HTML (accessibility, SEO, browser features) vừa tận dụng GPU rendering (shader, zoom, performance).

Nhận định: tương lai của web app phức tạp — workflow editors, design tools, AI canvas — là hybrid rendering:

  • HTML cho content: text, forms, accessibility
  • Canvas/WebGPU cho visuals: connections, effects, zoom
  • HTML-in-Canvas cho bridge: khi bạn cần cả hai trong cùng rendering context

Trong khi chờ API ổn định, HTML overlay pattern (như demo ở trên) vẫn là cách tốt nhất. Nó hoạt động hôm nay, trên mọi browser, và khi HTML-in-Canvas ship, migration path sẽ rõ ràng: thay overlay bằng drawElementImage().


Tài liệu tham khảo