ここ最近、Web Components が再び注目を集めています。

Eric Meyer 氏による「Blinded By the Light DOM」や、Jeremy Keith 氏による「HTML web components」をきっかけに、これまでの Web Components とは考え方や使用方法の異なる HTML Web Components という手法に関する記事をいくつも目にしました。

本記事では、この HTML Web Components とは何か。一般的な Web Components とは何が違うのか。そして、その特徴と使用方法について考えてみたいと思います。

まず、HTML Web Components に触れる前に、その前提となる Web Components の基本について説明します。

Web Components は以下の 3 つの技術で成り立っています。

  • カスタム要素
  • Shadow DOM
  • HTML テンプレート

これらの技術を組み合わせることで、自己完結型の再利用可能なコンポーネントを実装することが可能になります。ウェブ標準の技術であり、各種ブラウザのサポートも十分です。

カスタム要素は、JS のクラスによって HTML 要素を拡張させて作成したコンポーネントを、独自の HTML タグで使えるようにする機能です。

以下の例では、<my-lightdom> というカスタム要素を定義しています。

カスタム要素の例(Light DOM)
<!-- HTML -->
<my-lightdom name="Light DOM"></my-lightdom>

<!-- JS -->
<script>
class MyLightDOM extends HTMLElement {
  constructor() {
    super();

    // HTML や CSS を作成したり、機能を追加する
    const text = document.createElement('div');
    text.textContent = this.getAttribute('name');

    const style = document.createElement('style');
    style.textContent = `
      my-lightdom > div {
        padding: 1em;
        border: 1px solid currentColor;
        color: black;
        background: white;
        text-align: center;
      }
    `;
    this.append(style, text);

    this.addEventListener('click', this.sayHello);
  }
  sayHello = () => alert('Hello, Light DOM!');
}
customElements.define('my-lightdom', MyLightDOM);
</script>

このコンポーネントは Shadow DOM ではないので、スタイルがコンポーネントの外側に影響してしまいます。そのため、CSS セレクタは my-lightdom > div として、コンポーネントの内側にのみ適用されるように指定しています。

このコードで作成したカスタム要素は以下のようになります。クリックすると window.alert でメッセージが表示されるだけのコンポーネントです。

なお、このコンポーネントは説明向けに簡略化するために、<div> 要素に対してクリックイベントを設定しており、キーボードでは操作できないアクセシビリティ上の問題があります。

Live Demo

カスタム要素のおもなポイントは以下のとおりです。

  • HTMLElement を拡張したクラスを作成し、customElements.define() で定義する
  • コンストラクタの先頭で super() を指定する
  • 要素名はすべて小文字で、必ずハイフン(-)を含める(将来的に追加される HTML タグとの干渉を避けるため)
  • 要素名に日本語や絵文字(非 ASCII 文字)は使用不可1
  • カスタム要素は開始タグと終了タグの両方を指定する
  • カスタム要素には属性を指定できる
  • カスタム要素には子孫要素を含めることができる

加えて、ライフサイクルコールバックもカスタム要素の重要な概念ですが、本記事では割愛します。

続いて Shadow DOM ですが、カスタム要素のクラスを指定する過程で、作成した DOM 要素を Shadow Root に追加することで、コンポーネントをカプセル化することができます。

なお、Shadow DOM の外側にある DOM 要素は、Light DOM と呼ばれます。通常の HTML 要素や、Shadow DOM を追加していないカスタム要素は Light DOM に該当します。

以下の例では、<my-shadowdom> というカスタム要素を定義しています。

カスタム要素の例(Shadow DOM)
<!-- HTML -->
<my-shadowdom name="Shadow DOM"></my-shadowdom>

<!-- JS -->
<script>
class MyShadowDOM extends HTMLElement {
  constructor() {
    super();

    // Shadow Root を追加
    this.attachShadow({ mode: 'open' });

    const text = document.createElement('div');
    text.textContent = this.getAttribute('name');

    const style = document.createElement('style');
    style.textContent = `
      div {
        padding: 1em;
        border: 1px solid currentColor;
        color: white;
        background: black;
        text-align: center;
      }
    `;
    this.shadowRoot.append(style, text);

    this.addEventListener('click', this.sayHello);
  }
  sayHello = () => alert('Hello, Shadow DOM!');
}
customElements.define('my-shadowdom', MyShadowDOM);
</script>

Shadow DOM のおもなポイントは以下のとおりです。

  • attachShadow() メソッドで Shadow Root を追加
  • このとき、mode オプションを openclosed を選択する
  • クラスで作成した DOM 要素やスタイルやスクリプトを Shadow Root に追加する
  • Shadow DOM のスタイルは、Light DOM に影響しない
  • Light DOM のスタイルは、Shadow DOM にほぼ影響しない

上記のコードでは、div 要素にスタイルを適用していますが、Shadow DOM の外側(Light DOM)には影響しません。

先ほどのコンポーネントと並べると以下のようになります。

Live Demo

ブラウザのデベロッパーツールで確認すると、<my-lightdom> には直接 DOM ツリーが展開されていますが、<my-shadowdom> では、Shadow Root の下に格納されていることがわかります。

デベロッパーツールで DOM ツリーを表示した状態のスクリーンショット
Shadow DOM は #shadow-root の下に格納される

注意点としては、Light DOM のスタイルは、Shadow DOM に「ほぼ影響しない」と記述したように、継承可能なプロパティや CSS 変数(カスタムプロパティ)については、Light DOM から Shadow DOM へ影響を及ぼします。

試しに、以下のグローバルスタイルを追加すると、Shadow DOM と Light DOM それぞれの影響の違いを確認できます。

追加するグローバルスタイル
<style>
body {
  font-family: serif;
  text-transform: uppercase;
}
div {
  border-radius: 40px;
  font-family: sans-serif;
}
</style>

以下はライブデモですが、Light DOM では両方のスタイルが適用されますが、Shadow DOM は body セレクタに指定された、継承プロパティの値のみ反映されているのがわかります。

Live Demo

つまり、Shadow DOM では祖先要素にあたる body からのスタイルは継承されますが、div 要素セレクタの指定は影響を受けません。

このように、Shadow DOM ではカプセル化によって、内外の影響を心配する必要は少なくなるのですが、スタイルの継承による影響に気付きづらいのが難点です。

同時に、外部から影響を受けないということは、コンポーネント内でブラウザなどのユーザエージェントのスタイルをリセットする必要があり、スタイル指定が煩雑かつ冗長になります。

加えて、Shadow DOM は Light DOM への紐づけができないため、アクセシビリティ上の問題が発生しないように注意する必要があります。

例えば、Light DOM の <label> 要素 から、Shadow DOM の <input> 要素にはアクセスできません。もちろん、Shadow DOM 同士であっても同様です。

<input> と <label> の紐づけができない例
<!-- 🙅‍♂️ Light DOM から Shadow DOM にアクセスできない -->
<label for="text">Label</label>
<custom-input></custom-input>

<script>
class CustomInput extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    const input = document.createElement('input');
    input.setAttribute('type', 'text');
    input.setAttribute('id', 'text');

    this.shadowRoot.append(input);
  }
}
customElements.define('custom-input', CustomInput);
</script>

このように、Shadow DOM はカプセル化の恩恵は受けられる反面、一般的なウェブサイトにおいては気軽に使えるシーンは少ないように感じます。

本記事では HTML テンプレートの詳細な説明は割愛しますが、ブラウザ上では描画されない <template> 要素を使用して、繰り返し使用する HTML 構造を定義したり、<slot> 要素を使用して、Shadow DOM に Light DOM のコンテンツを挿入することができます。

なお、<template> 要素については、Web Components でカスタム要素を作成する文脈に限らず、通常の HTML 要素に対しても活用できます。

それでは、ここまでの Web Components の説明を踏まえて、HTML Web Components とは何かを見ていきますが、改めてカスタム要素で説明した特徴に注目します。

  • カスタム要素には子孫要素を含めることができる

前述の Web Components の例では、<my-component></my-component> のように中身が空のカスタム要素を配置して、HTML 自体は JS 側で動的に生成していましたが、カスタム要素の内側には通常の HTML 要素を含めることができます。

以下のレンジスライダーをカスタマイズする例で、もう少し詳しく説明していきます。まずは、以下の HTML をベースとします。

カスタム要素の内側に HTML 要素を含めた例
<custom-range>
  <label for="c-slider">Label</label>
  <div class="c-slider-bg">
    <input type="range" value="50" min="0" max="100" id="c-slider">
  </div>
</custom-range>

<custom-slider> という要素で囲われていますが、まだカスタム要素を定義していない状態です。このとき、<custom-slider> の内側に配置された HTML がそのまま表示されます。

Live Demo

CSS を追加して以下のような見た目に変更します。まだカスタム要素は定義していません。

Live Demo

この状態でも、レンジスライダーとしては最低限の機能を確保していますが、ここからさらに以下の 2 つの機能を追加します。

  1. スライダーの値に応じてトラックの長さを変更
  2. スライダーの値をアウトプットして表示
Live Demo
50

コードは以下のとおりで、HTML に <output> 要素を追加したのと、JS でカスタム要素を定義しています。重要な点として、このカスタム要素は Shadow DOM ではなく Light DOM です。

HTML Web Components の例
<!-- HTML -->
<custom-range>
  <label for="c-slider">Label</label>
  <div class="c-slider-bg">
    <input type="range" value="50" min="0" max="100" id="c-slider">
  </div>
  <output for="c-slider">50</output>
</custom-range>

<!-- JS -->
<script>
class CustomRange extends HTMLElement {
  constructor() {
    super();

    const range = this.querySelector('input[type="range"]');

    if (!range) return;

    const min = range?.getAttribute('min') ?? '0';
    const max = range?.getAttribute('max') ?? '100';
    const output = this.querySelector('output');

    this.style.setProperty('--progress', range.value);
    this.changeProgressStyle(range.value, min, max);
    this.changeOutputValue(range.value, output);

    range.addEventListener('input', () => {
      this.changeProgressStyle(range.value, min, max);
      this.changeOutputValue(range.value, output);
    });
  }

  // 1. スライダーの値に応じてトラックの長さを変更
  changeProgressStyle(value, min, max) {
    const progress = (Number(value) - Number(min)) / (Number(max) - Number(min)) * 100;
    this.style.setProperty('--progress', progress);
  }

  // 2. スライダーの値をアウトプットして表示
  changeOutputValue(value, output) {
    output.value = String(value);
  }
}
customElements.define('custom-range', CustomRange);
</script>

CSS はコード量が多いので折りたたんでいます。

CSS を確認する
<style>
custom-range {
  --color-fg: #007373;
  --color-bg: #fff;
  --color-hover: #005454;

  --thumb-inline-size: 16px;
  --thumb-block-size: 16px;
  --track-inline-size: 100%;
  --track-block-size: 2px;
  --active-scale: 1.3;
  --progress: 100;

  display: grid;
  gap: 1.5em;
  inline-size: 100%;
  padding: 40px;
  border-radius: 16px;
  background-color: hsl(180 37% 81%);
  text-align: center;
}
@media (hover: hover) and (pointer: fine) {
  custom-range:has(input[type="range"]:hover) {
    --color-fg: var(--color-hover);
  }
}
custom-range label {
  inline-size: fit-content;
  font-size: calc(1rem * 14 / 16);
  line-height: 1.1;
  text-align: left;
}
custom-range output {
  display: inline-grid;
  place-items: center;
  margin: auto;
  border-radius: 8px;
  inline-size: 3em;
  aspect-ratio: 1;
  background-color: var(--color-fg);
  color: var(--color-bg);
  transition: all 0.2s ease;
}
custom-range:has(input[type="range"]:active) output {
  font-weight: 400;
  scale: var(--active-scale);
}
custom-range input[type="range"] {
  position: relative;
  z-index: 1;
  display: block;
  inline-size: 100%;
  block-size: var(--thumb-block-size);
  background: none;
  outline: none;
  -webkit-appearance: none;
  appearance: none;
}
custom-range input[type="range"]::after {
  content: '';
  margin-block: auto;
  position: absolute;
  border-radius: var(--track-block-size);
  inset-block: 0;
  inset-inline-start: 0;
  display: block;
  inline-size: 100%;
  block-size: var(--track-block-size);
  background-color: var(--color-fg);
  transform-origin: 0 50%;
  scale: calc(var(--progress) * 0.01) 1;
}
custom-range .c-slider-bg {
  position: relative;
}
custom-range .c-slider-bg::before,
custom-range .c-slider-bg::after {
  content: '';
  position: absolute;
  inset: 0;
  display: block;
  margin: auto;
  background-color: var(--color-bg);
}
custom-range .c-slider-bg::before {
  inline-size: 2px;
  block-size: 4px;
  translate: 0 4px;
}
custom-range .c-slider-bg::after {
  inline-size: 100%;
  block-size: var(--track-block-size);
}

/* webkit */
custom-range input[type="range"]::-webkit-slider-thumb {
  position: relative;
  z-index: 10;
  inline-size: var(--thumb-inline-size);
  block-size: var(--thumb-block-size);
  border-radius: var(--thumb-block-size);
  box-shadow: none;
  background-color: var(--color-fg);
  appearance: none;
  cursor: grab;
  transition: all 0.2s ease;
}
custom-range input[type="range"]:active::-webkit-slider-thumb {
  scale: var(--active-scale);
}
custom-range input[type="range"]:focus::-webkit-slider-thumb {
  border: 1px solid var(--color-bg);
  box-shadow: 0 0 0 2px var(--color-fg);
}
custom-range input[type="range"]::-webkit-slider-runnable-track {
  border: none;
  cursor: pointer;
  appearance: none;
}

/* moz */
custom-range input[type="range"]::-moz-range-thumb {
  position: relative;
  z-index: 10;
  inline-size: var(--thumb-inline-size);
  block-size: var(--thumb-block-size);
  border-radius: var(--thumb-block-size);
  box-shadow: none;
  background-color: var(--color-fg);
  appearance: none;
  cursor: grab;
  transition: all 0.2s ease;
}
custom-range input[type="range"]:active::-moz-range-thumb {
  scale: var(--active-scale);
}
custom-range input[type="range"]:focus::-moz-range-thumb {
  border: 1px solid var(--color-bg);
  box-shadow: 0 0 0 2px var(--color-fg);
}
custom-range input[type="range"]::-moz-range-track {
  border: none;
  cursor: pointer;
  appearance: none;
}
</style>

1 つ目の「スライダーの値に応じてトラックの長さを変更」では、CSS 変数(--progress)経由でトラックの長さを変更して、視覚的なわかりやすさを調整しています。

2 つ目の「スライダーの値をアウトプットして表示」は、単純に <input type="range"> の値を <output> 要素に指定しています。

クラス内の this キーワードはカスタム要素自体を指すので、コンポーネントの範囲に限定した要素の取得や値の指定が可能になり、document.querySelector() などと比較すると、スコープを特定しやすく、コンポーネント指向な実装が可能になります。

このように、ベースとなる HTML / CSS に対して、JS の機能を積み上げる実装が可能になることが、HTML Web Components の最大の特徴といえます。

これらの点を踏まえた HTML Web Components の特徴は以下のとおりです。

  • Light DOM である
  • Progressive Enhancement である2
  • コンポーネント指向である
  • アクセシビリティが確保しやすい
  • レイアウトシフトの影響が小さく、パフォーマンスに優れている3
  • グローバルスタイルを使用できる(反面、外部から影響を受けるデメリットもある)
  • ウェブ標準なので環境を選ばない
  • ブラウザでそのまま実行できるのでビルドが不要
  • ロックインやバージョン管理によるメンテナンスの心配がない

一方で、HTML Web Components の課題としては以下が考えられそうです。

  • スタイルの管理方法が確立していない
  • HTML の一元管理ができない

スタイルの管理については次項で説明します。

HTML の管理に関しては、Astro や Eleventy といったフレームワークを使用して、コンポーネントをファイルごとに一元管理する方法が現実的です。その場合には、ロックインやメンテナンスコストとのトレードオフになりますが、ある程度はやむを得ないと考えています。

HTML Web Components のスタイル管理

見出し「HTML Web Components のスタイル管理」

HTML Web Components は Light DOM なので、CSS のスコープは閉じられません。そのため、スコープを限定させるために、カスタム要素を起点としたスタイル指定が有効です。

このとき、ネスト記法(CSS Nesting)を使用すると、効率的に記述できます。

ネスト記法(CSS Nesting)は、主要なブラウザでサポートされていますが、Safari でサポートが開始されたのが 16.5 からなので注意が必要です。

また、コンポーネント外部のスタイルとの競合を避けるために、クラスには接頭辞 c- を付けるといったルールを持たせるとよいかもしれません。

カスタム要素にスタイルを指定する例(CSS Nesting)
my-component {
  /* ... */

  & h1 {
    /* ... */
  }
  .c-date {
    /* ... */
  }
}

なお、将来的に @scope 規則のサポートが十分になれば接頭辞は不要になり、以下のように簡潔に指定できるようになります。

カスタム要素にスタイルを指定する例(@scope)
@scope (my-component) {
  :scope {
    /* ... */
  }
  h1 {
    /* ... */
  }
  .date {
    /* ... */
  }
}

注意する点としては、入れ子にしたカスタム要素にもスタイルが影響する可能性があることです。

入れ子にしたカスタム要素にスタイルが影響する例
<!-- HTML -->
<my-component>
  <p>My component</p>

  <another-component>
    <p>Another component</p>
  </another-component>
</my-component>

<!-- CSS -->
<style>
my-component {
  & p {
    color: teal;
  }
}
</style>

以下のように @scope 規則で範囲(donut scope)を示せば、ある程度避けることはできますが、その場合にも子孫要素に含まれるカスタム要素名を事前に知っておく必要があります。

@scope で範囲を明示する例
@scope (my-component) to (another-component) {
  p {
    color: teal;
  }
}

カスタム要素のデフォルトスタイル

見出し「カスタム要素のデフォルトスタイル」

カスタム要素のデフォルトの display の値は inline なので、レイアウトに応じて display の値を変更してあげる必要があります。

例えば、先ほどのレンジスライダーの例で、カスタム要素 <custom-range>display がデフォルトの inline のままだと、領域が確保されずに以下のように表示が崩れます。

カスタム要素 `<custom-range>` の表示が崩れた状態のスクリーンショット
display を指定しないと、領域が確保されずに表示が崩れることがある

すべてのカスタム要素にスタイルをまとめて指定する方法が存在しないのが、もどかしく感じますが、カスタム要素ごとに display の値を指定する必要があります。

:defined 擬似クラス

この見出しのリンク

CSS の :defined 擬似クラスを使用することで、カスタム要素が定義されたときのスタイルを定義できます。以下のように :not() 擬似クラスとあわせることで、カスタム要素が定義されるまでのスタイルが指定できます。

カスタム要素が定義されるまでに適用するスタイルの例
<!-- HTML -->
<my-component>
  <!-- ... -->
</my-component>

<!-- CSS -->
<style>
my-component:not(:defined) {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>

カスタム要素の生成に時間を要する場合には、操作不可であることを表すスタイルを追加できます。その場合には、aria-busy 属性を追加するなどして、スクリーンリーダーなどの支援技術にも状態が伝わるようにするとよいでしょう。

この記事では HTML Web Components について、個人的に理解している内容をまとめました。

今後、HTML Web Components という呼び方が定着するかはわかりませんが、HTML をベースに機能を積み増していく Progressive Enhancement の思想に基づいており、合理的で有効なテクニックだと感じています。

一方で、CSS や HTML の管理方法については若干手探りなところもあり、実際に使用しながらベストプラクティスを見つけていければと考えています。

HTML Web Components については、以下のウェブページを参考にしました。

Web Components 全般については、以下のウェブページを参考にしました。

脚注

  1. Valid custom element names | HTML Standard

  2. プログレッシブエンハンスメント(Progressive Enhancement)という考えかた | Accessible & Usable

  3. Flash of Unstyled Custom Element(FOUCE)と呼ばれる、JS が実行されてカスタム要素が有効になった瞬間のレイアウトのずれや、ちらつきを回避することができます。