HTML-in-Canvas: 技術的なコンセプト

HTML-in-Canvas API を使用すると、インタラクティブでアクセス可能な DOM サブツリーを 2D、WebGL、WebGPU のキャンバス コンテキストに直接レンダリングできます。

このガイドでは、すべてのキャンバス コンテキストに共通する HTML-in-Canvas の技術的なコンセプトについて説明します。

HTML をレンダリングするようにキャンバスを準備する

<canvas> 内で HTML 要素をレンダリングするには、次の 2 つの属性を構成します。

  • <canvas> に content="drawable" を設定する: これにより、キャンバス要素は、子孫 DOM ノードを隠しコンテンツとしてのみ扱うのではなく、レイアウトに組み込むようになります。
  • ターゲット要素に drawable を設定する: これにより、要素がキャンバス レンダリングに適した独立したペイントルートとしてマークされます。
<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>

ドローアブル サブツリーを理解する

HTML をキャンバスにレンダリングする準備をする際には、どの要素を個別に描画するかを決定する必要があります。各 drawable 要素は、独立したペイントルートとそのドローアブル サブツリーを表します。

ドローアブル サブツリーは、drawable 属性を持つ要素とそのすべての子孫で構成されます。ただし、drawable を持つネストされた子孫は除きます。例:

<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>
要素 境界をペイントする 動作
#parentElement(サブツリー A) 背景パネル、見出し、段落 drawElementImage(parentElement, ...) の呼び出し時にレンダリングされます。
#nestedChildElement(サブツリー B) ボタンのサーフェスとテキスト #parentElement サブツリーから除外され、別の drawElementImage(nestedChildElement, ...) 呼び出しでレンダリングされる
drawable ボタンは drawable カード内にネストされており、それぞれが独自の drawElementImage() 呼び出しによって描画されます。

描画可能な要素をネストすると、セマンティック HTML の関係を維持しながら、キャンバス シーン上で子要素を個別にレイヤ化、配置、アニメーション化できます。

paint イベントについて理解する

HTML-in-Canvas API には paint イベントが導入されています。ブラウザは、drawable の子孫のレイアウト、テキスト、CSS スタイルが変更されるたびに、<canvas> で paint イベントを自動的にディスパッチします。

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

paint イベントは CanvasPaintEvent のインスタンスをディスパッチします。これは標準の DOM Event を拡張したもので、最後のペイント以降に視覚的に変化した(スタイル変更、レイアウト シフト、テキスト編集、フォーカス状態の変更など)drawable 子孫のみを含む changedElements プロパティを備えています。

canvas.addEventListener("paint", (event) => {
  // Array of changed elements
  for (const el of event.changedElements) {
    console.log(`Element #${el.id} visually changed and needs repainting`);
  }
});
ブラウザは、描画可能な子孫のレンダリングが変更されるたびに、キャンバス上でペイントを起動します。

フレームのライフサイクル

paint イベント内で実行された Canvas 描画コマンドは現在のレンダリング更新に表示されますが、paint イベント内で行われた DOM 変更は、次のフレームまで表示されません。

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

ネストされたキャンバス

ページにネストされた <canvas content="drawable"> 要素が含まれている場合、ブラウザは DOM ツリーの逆順(祖先より前に子孫が起動)で paint イベントをディスパッチします。これにより、親キャンバス要素が子キャンバス要素を合成する前に、子キャンバス要素がレンダリングされるようになります。

次の例では、内側の nestedElement 要素を視覚的に変更する必要があるとします(たとえば、ユーザーがポインタを置いたときに CSS の背景色を変更するなど)。まず、paint イベントが nestedCanvas キャンバスで発生します。リスナーは子バッファをクリアし、ctx.drawElementImage(nestedElement, ...) を呼び出して更新された状態をレンダリングします。その後、parentCanvas で paint イベントが配信されます。そのリスナーは、wrapperElement(更新済みの nestedCanvas キャンバスを含む)をシーンに描画します。

<!-- 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>
<canvas content="drawable"> 要素がネストされている場合、ペイント イベントはツリーの逆順で発生します。ネストされたキャンバスが最初にペイントされるため、親キャンバスは最新のピクセルを描画できます。

paint イベントをトリガーする

requestPaint() を呼び出して、DOM テキストやスタイルが変更されていない場合でも、次のフレームで単一の paint イベントをスケジュールします。これは、移動する要素を含むインタラクティブなシーンをレンダリングするために使用できます。たとえば、次のようなものです。

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

要素のジオメトリを更新する

HTML 要素をキャンバスに描画する場合、描画コードによって要素の表示位置とサイズが決まります。通常の HTML コンテンツとは異なり、要素の画面上の位置とサイズ(ジオメトリ)は、計算されたスタイルだけでは決定できません。ブラウザは、次の処理を行うために要素のジオメトリを必要とします。

  • クリック、タップ、ポインタの移動などの入力イベントを適切な要素に送信します。これはヒットテストと呼ばれます。
  • getBoundingClientRect() などのレイアウト クエリから正確な結果を返す。
  • 要素の境界を支援技術(拡大鏡など)に報告する。

2D では、drawElementImage() はデフォルトでジオメトリを自動的に更新します。これに渡す引数と、要素の計算されたスタイル、コンテキストの現在の変換を組み合わせることで、要素が表示される場所を特定できます。

WebGL と WebGPU のコンテキストでは、ブラウザはジオメトリを特定できません。HTML 要素がテクスチャにコピーされ、シェーダー プログラムは、そのテクスチャを任意の方法でシーンにマッピングできます。要素が表示される場所をブラウザに伝えるには、updateElementGeometry() に変換を渡します。

コードが updateElementGeometry() の呼び出しを停止すると、ブラウザは 3D カード上の HTML 要素の位置を認識できなくなります。カードは回転し続けますが、クリックすると以前の場所に移動します。

ジオメトリを更新するには、空間変換をメソッドに渡します。さまざまなコンテキストで変換を構築する方法を学習します。

ヒットテストの重ね順を管理する

デフォルトでは、要素のジオメトリが更新されるたびに、ブラウザはその要素をインタラクション スタックの一番上に移動します(最後に描画された要素 = 最上位のクリック ターゲット)。この動作は preserveHitTestOrder を使用して制御できます。

// Updates the backdrop without stealing clicks from foreground elements
canvas.updateElementGeometry(backgroundElement, {
  canvasTransform: customTransform,
  preserveHitTestOrder: true
});
オプション 動作 ユースケース
preserveHitTestOrder: false(デフォルト) 要素をヒットテスト スタックの一番上に移動します。 最初にクリックを受け取る必要があるフォアグラウンド要素。
preserveHitTestOrder: true 要素を現在のスタック優先度のままにします。 前景要素の後に更新する必要がある背景要素。

ジオメトリをリセット

DOM から要素を削除せずに描画を解除するには、canvas.clearElementGeometry() を呼び出します。これにより、要素のキャンバス変換がリセットされ、ユーザー補助ツリーで画面外としてマークされます。

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

サイズ設定と単位

HTML をキャンバスに描画すると、ブラウザは CSS ピクセル、デバイス ピクセル、キャンバス グリッド ピクセルの 3 つの座標系で同時に動作します。

CSS ピクセルは、レイアウト中にコンテンツの測定と配置に使用されます。ブラウザは、これらのピクセルを devicePixelRatio でスケーリングして、ページを実機のピクセルにラスタライズします。CSS ピクセルは論理単位であるため、位置とサイズはデバイス ピクセルの端数になることがよくあります(例: 10.5 CSS px × DPR 1.5 = 15.75 デバイス px)。画面はピクセル全体にしか色を付けることができないため、ブラウザはピクセル間に収まるものをすべて解決する必要があります。これは次の 2 つの方法で行われます。

  • スナップ: ボックスの境界、枠線、レイヤのオフセットは、最も近いデバイスのピクセルに丸められ、鮮明に表示されます。
  • アンチエイリアス: テキスト グリフ、曲線、変換されたエッジの場合、部分的なカバレッジが隣接するピクセルにブレンドされるため、滑らかで適切な位置に表示されます。

<canvas> には、width 属性と height 属性によって設定される独自のピクセル グリッドがあり、CSS ピクセルとデバイス ピクセルの両方から分離されています。これを表示するために、ブラウザはキャンバス ビットマップを物理ピクセル グリッドにリサンプリングし、隣接するキャンバス ピクセルをブレンドして各デバイス ピクセルを埋めます。ブラウザが CSS、キャンバス、デバイスのピクセルをどのようにマッピングするかについては、devicePixelContentBox を使用したピクセル パーフェクト レンダリングをご覧ください。

drawable HTML 要素は CSS ピクセルでレイアウトされます。キャンバスに描画されたコンテンツは、CSS ピクセルからキャンバス グリッド、キャンバス グリッドから物理ピクセルという 2 つの変換を経る可能性があるため、正確に一致しない各ステップで別のブレンドが追加され、結果がぼやけて見えることがあります。これを回避するには、キャンバス グリッドを物理ピクセル グリッドに合わせます。これには、次の 2 つの目的があります。

  • 一貫したレンダリング: キャンバスが物理ピクセルに 1:1 でマッピングされる場合、CSS ピクセルをキャンバス ピクセルに変換すると、通常の HTML でブラウザが CSS ピクセルを物理ピクセルに変換する場合と同じ結果になります。
  • 追加のブレンドなし: キャンバスが物理ピクセルに 1:1 でマッピングされている場合、デバイス ピクセルに変換してもぼかしは追加されません。

次の画像は、ピクセル変換によってコンテンツがぼやけていることを示しています。3 つのグリッドが一致していません。キャンバス要素は 360 × 180 CSS ピクセルで、キャンバス サイズは 300 × 150 キャンバス グリッド ピクセルです。デバイスのピクセル比が 1.5 の場合、キャンバスは 540 × 270 のデバイス ピクセルをカバーします。1 CSS ピクセルは 1.5 デバイス ピクセルに相当し、1 キャンバス ピクセルは 1.2 CSS ピクセル(1.8 デバイス ピクセル)に相当します(パネル 1)。円が描画されるとき、その位置と半径は CSS ピクセルではなくキャンバス ピクセルで表されるため、ブラウザは 300 × 150 のキャンバス ビットマップに円を描画し、エッジをアンチエイリアス処理します(パネル 2)。キャンバスを表示するために、ブラウザはこのビットマップを 540 × 270 のデバイス ピクセルに引き伸ばします。そのため、ブラウザは補間を行う必要があります。各デバイス ピクセルは、最も近いキャンバス ピクセル(すでにアンチエイリアス処理されたエッジを含む)の加重混合を取得します。エッジが 2 回目にブレンドされ、複数のデバイス ピクセルにまたがって広がるため、円がぼやけて見えます(パネル 3)。これは、キャンバス グリッドがデバイスのピクセルと 1:1 で一致していれば回避できます。ブラウザは、デバイスのグリッド上で直接、エッジを 1 回だけアンチエイリアス処理し、リサンプリングなしでビットマップを表示するため、エッジは鮮明なままです(パネル 4)。

曲線エッジのピクセルグリッドを比較した 4 パネルの図: 1. CSS(360x180)、キャンバス(300x150)、デバイス(540x270)のグリッドが揃っていません。2. 300x150 の粗いキャンバス グリッドにアンチエイリアス処理された曲線。3. そのビットマップが 540x270 のデバイス グリッドに補間され、2 回目のアンチエイリアス処理が行われてエッジがぼやけます。4. デバイスのグリッドに 1 対 1 で対応する 540x270 のキャンバス。エッジを鮮明にするためにアンチエイリアスを 1 回適用。
CSS、キャンバス、デバイスのピクセルグリッドが揃っていない場合に、二重のアンチエイリアスが発生する仕組み(パネル 1 ~ 3)。パネル 4 は、キャンバスとデバイスのグリッドが 1:1 で揃っている場合。

鮮明にレンダリングするには、キャンバス グリッドが、キャンバスがカバーするデバイス ピクセル数(上記の例では 540 × 270)と一致している必要があります。

「Hello from the DOM!」と表示された HTML カードの並べて比較このカードは、キャンバスに描画された HTML です。左: デフォルトの 300x150 のキャンバス グリッドに描画され、ぼやけて見えます。右: ResizeObserver でサイズ設定された 800x400 のキャンバス グリッドに描画され、鮮明に表示されています。
同じキャンバス(400 x 200 CSS ピクセル)を DPR が 1.5 の画面に表示。左: 400 x 200 のキャンバス グリッド。右: DPR に合わせて調整された 600 x 300 のグリッド。

サイズ変更オブザーバーを使用してキャンバス グリッドのサイズを変更する

適切なグリッドサイズはデバイスによって異なります。また、レイアウトの変更、ユーザーによるページのズーム、ユーザーによるウィンドウの別のディスプレイへの移動によっても変化する可能性があります。そのため、グリッドサイズをハードコードするのではなく、ブラウザにグリッドサイズを指示させます。

キャンバス グリッドを画面上のキャンバスと同期させるには、常に ResizeObserver でサイズを設定し、キャンバス グリッドを 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);

CSS ピクセルをキャンバス グリッド座標に変換する

paint ハンドラでは、DOM の測定と描画呼び出しを組み合わせることがよくあります。たとえば、要素のサイズに基づいて要素を配置する場合などです。この 2 つは異なる単位を使用します。

  • キャンバス ピクセルは、canvas.width と canvas.height でサイズが指定されるキャンバス グリッドを表します。たとえば、drawElementImage() 描画メソッドの dx、dy、dwidth、dheight パラメータや、fillRect()、translate() などの他の Canvas API パラメータです。
  • CSS ピクセルはページを記述します。offsetWidth や clientWidth などの DOM 測定値と、offsetX などのポインタ イベント座標は CSS ピクセル単位です。

さまざまな API パラメータで使用される単位の概要は次のとおりです。

値 単位
canvas.width、canvas.height キャンバス グリッドのピクセル
2D 描画パラメータ(drawElementImage() の dx、dy、dwidth、dheight など) キャンバス グリッドのピクセル
canvas.getElementImageDefaultSize() が width と height を返しました キャンバス グリッドのピクセル
ソースの長方形: drawElementImage() と texElementSubImage2D() の sx、sy、swidth、sheight、drawElementImageToTexture() の sourceX、sourceY、sourceWidth、sourceHeight CSS ピクセル
テクスチャ サイズ: texElementSubImage2D() の width オプションと height オプション、drawElementImageToTexture() の size テクスチャ ピクセル。デフォルトでは、要素のサイズ(キャンバス グリッドのピクセル単位、切り上げ)
要素のジオメトリ: getElementTransform()、updateElementGeometry() の canvasTransform オプションと canvasClip オプション CSS ピクセル
DOM 測定値(offsetWidth、clientWidth、getBoundingClientRect()、ポインタイベント座標、サイズ変更オブザーバー エントリの contentBoxSize など) CSS ピクセル
サイズ変更オブザーバー エントリの devicePixelContentBoxSize デバイスのピクセル

間違った単位を使用すると、キャンバスに描画される HTML コンテンツのサイズが想定どおりにならない可能性があります。HTML をキャンバスに描画する際は、CSS ピクセル値をキャンバス グリッド ピクセルに変換する必要があります。これを行うには、キャンバスのグリッドサイズとキャンバスの CSS サイズの比率を計算します。水平方向は canvas.width / cssContentWidth、垂直方向は canvas.height / cssContentHeight です。サイズ変更オブザーバーは、次のように比率を計算するのに便利な場所です。

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

次に、CSS ピクセル値に scaleX と scaleY を掛けてから、描画メソッドに渡します。たとえば、キャンバス内でドローアブル要素を中央に配置するには、次のようにします。

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

最後に、drawElementImage() は要素のボーダーボックスを描画するため、box-sizing: border-box を使用して、カードの width と height にパディングとボーダーを含めます。これにより、カードは設定したサイズで描画されます。

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

HTML-in-Canvas のユーザー補助機能について

一般的に、<canvas content="drawable"> の HTML コンテンツは、通常の DOM と同じ方法でユーザー補助機能に公開されます。つまり、aria-hidden="true" や inert などの属性を使用して明示的に非表示にしない限り、ユーザー補助ツリーに表示されます。ただし、1 つの重要な例外があります。それは、レンダリングされたジオメトリがまだ確立または更新されていない drawable 要素です。

<canvas content="drawable"> 内の drawable 要素にレンダリングされたジオメトリがない場合、次のようになります。

  • drawElementImage() でまだレンダリングされていません。デフォルトでは、 でジオメトリが自動的に更新されます。
  • そのジオメトリは updateElementGeometry() で明示的に更新されていません。

drawable 要素にレンダリングされたジオメトリがない場合、この drawable 要素とその drawable サブツリーは、ジオメトリ情報なしでユーザー補助ツリーに報告されます。これは、たとえば、スクリーン リーダーが意味的内容にアクセスできるようにしながら、視覚補助ツール(拡大鏡など)に描画されていない要素を除外するように指示する場合に役立ちます。

次の例では、画像と図を別々に描画しながら、図、画像、キャプションのセマンティクスを保持しています。

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

代替戦略

HTML-in-Canvas API は Chrome 157 でリリースされましたが、すべてのブラウザでサポートされているわけではありません。ソリューションがすべてのユーザーに対してスムーズに機能するように、ブラウザが API をサポートしているかどうかを確認し、必要に応じて代替ソリューションにフォールバックします。

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

API がサポートされていない場合は、実装の目標に応じて、次のいずれかの代替手段を選択できます。

次のステップ