Nordhealth がウェブ コンポーネントでカスタム プロパティを使用する方法

デザイン システムとコンポーネント ライブラリでカスタム プロパティを使用するメリット。

David Darnes
David Darnes

私は Nordhealth のシニア フロントエンド デベロッパーの Dave です。私は、デザイン システム Nord の設計と開発に携わっており、コンポーネント ライブラリ用のウェブ コンポーネントの構築も行っています。CSS カスタム プロパティを使用して Web コンポーネントのスタイル設定に関する問題を解決した方法と、デザイン システムやコンポーネント ライブラリでカスタム プロパティを使用するその他のメリットについてご紹介します。

ウェブ コンポーネントの構築方法

ウェブ コンポーネントを構築するために、Lit を使用します。Lit は、状態、スコープ付きスタイル、テンプレートなど、多くのボイラープレート コードを提供するライブラリです。Lit は軽量であるだけでなく、ネイティブ JavaScript API を基盤として構築されているため、ブラウザにすでに備わっている機能を活用した、無駄のないコードバンドルを提供できます。


import {html, css, LitElement} from 'lit';

export class SimpleGreeting extends LitElement {
  static styles = css`:host { color: blue; font-family: sans-serif; }`;

  static properties = {
    name: {type: String},
  };

  constructor() {
    super();
    this.name = 'there';
  }

  render() {
    return html`

Hey ${this.name}, welcome to Web Components!

`
; } } customElements.define('simple-greeting', SimpleGreeting);
Lit で記述されたウェブ コンポーネント。

しかし、ウェブ コンポーネントの最も魅力的な点は、既存のほぼすべての JavaScript フレームワークで動作すること、さらにはフレームワークがなくても動作することです。メインの JavaScript パッケージがページで参照されると、Web Components はネイティブ HTML 要素とほぼ同じように使用できます。ネイティブの HTML 要素ではないことを示す唯一の明確な兆候は、タグ内の一貫したハイフンです。これは、これがウェブ コンポーネントであることをブラウザに示す標準です。

Shadow DOM スタイルのカプセル化

ネイティブ HTML 要素に Shadow DOM があるのと同様に、ウェブ コンポーネントにも Shadow DOM があります。Shadow DOM は、要素内のノードの隠しツリーです。これを視覚化する最適な方法は、ウェブ インスペクタを開いて [Show Shadow DOM tree] オプションをオンにすることです。この設定を有効にすると、インスペクタでネイティブの入力要素を調べるときに、その入力要素を開いて内部のすべての要素を表示できるようになります。これは、ウェブ コンポーネントの 1 つでも試すことができます。カスタム入力コンポーネントを検査して、その Shadow DOM を確認してみてください。

DevTools で検査された Shadow DOM。
通常のテキスト入力要素と Nord 入力ウェブ コンポーネントの Shadow DOM の例。

Shadow DOM の利点(または見方によっては欠点)の 1 つは、スタイルのカプセル化です。ウェブ コンポーネント内に CSS を記述すると、それらのスタイルが漏れてメインページや他の要素に影響を与えることはありません。それらのスタイルは完全にコンポーネント内に含まれます。また、メインページまたは親ウェブ コンポーネント用に記述された CSS がウェブ コンポーネントに漏洩することもありません。

このスタイルのカプセル化は、コンポーネント ライブラリの利点です。これにより、親ページに適用されているスタイルに関係なく、コンポーネントが意図したとおりに表示されることが保証されます。さらに、すべてのウェブ コンポーネントのルート(「ホスト」)に all: unset; を追加します。


:host {
  all: unset;
  display: block;
  box-sizing: border-box;
  text-align: start;
  /* ... */
}
シャドウルートまたはホストセレクタに適用されるコンポーネントのボイラープレート コード。

しかし、ウェブ コンポーネントを使用しているユーザーに、特定のスタイルを変更する正当な理由がある場合はどうすればよいでしょうか。コンテキストに応じて、テキスト行のコントラストを強めたり、枠線を太くしたりする必要があるかもしれません。コンポーネントにスタイルを適用できない場合、どのようにしてスタイリング オプションを有効にすればよいでしょうか。

そこで CSS カスタム プロパティの出番です。

CSS カスタム プロパティ

カスタム プロパティは、その名のとおり、完全に独自の名前を付けて、必要な値を適用できる CSS プロパティです。唯一の要件は、2 つのハイフンを接頭辞として付けることです。カスタム プロパティを宣言すると、var() 関数を使用して CSS で値を使用できます。


:root {
  --n-color-accent: rgb(53, 89, 199);
  /* ... */
}

.n-color-accent-text {
  color: var(--n-color-accent);
}
カスタム プロパティとしてのデザイン トークンと、ヘルパークラスで使用されているデザイン トークンの CSS フレームワークの例。

継承に関しては、すべてのカスタム プロパティが継承されます。これは、通常の CSS プロパティと値の一般的な動作に従います。親要素または要素自体に適用されたカスタム プロパティは、他のプロパティの値として使用できます。Google では、CSS フレームワークを使用してルート要素に適用することで、デザイン トークンにカスタム プロパティを多用しています。つまり、ウェブ コンポーネント、CSS ヘルパークラス、トークンのリストから値を取得したいデベロッパーなど、ページ上のすべての要素でこれらのトークン値を使用できます。

var() 関数を使用してカスタム プロパティを継承するこの機能は、ウェブ コンポーネントの Shadow DOM を貫通し、デベロッパーがコンポーネントのスタイル設定をより細かく制御できるようにする方法です。

Nord ウェブ コンポーネントのカスタム プロパティ

デザイン システムのコンポーネントを開発する際は、CSS に慎重なアプローチを取っています。無駄がなく、メンテナンスしやすいコードを目指しています。デザイン トークンは、ルート要素のメイン CSS フレームワーク内でカスタム プロパティとして定義されています。


:root {
  --n-space-m: 16px;
  --n-space-l: 24px;
  /* ... */
  --n-color-background: rgb(255, 255, 255);
  --n-color-border: rgb(216, 222, 228);
  /* ... */
}
ルートセレクタで定義されている CSS カスタム プロパティ。

これらのトークン値は、コンポーネント内で参照されます。CSS プロパティに直接値を適用する場合もありますが、新しいコンテキスト カスタム プロパティを定義して、そこに値を適用する場合もあります。


:host {
  --n-tab-group-padding: 0;
  --n-tab-list-background: var(--n-color-background);
  --n-tab-list-border: inset 0 -1px 0 0 var(--n-color-border);
  /* ... */
}

.n-tab-group-list {
  box-shadow: var(--n-tab-list-border);
  background-color: var(--n-tab-list-background);
  gap: var(--n-space-s);
  /* ... */
}
コンポーネントのシャドウルートで定義され、コンポーネント スタイルで使用されているカスタム プロパティ。デザイン トークンのリストのカスタム プロパティも使用されています。

また、コンポーネントに固有で、トークンに含まれていない一部の値を抽象化し、コンテキスト カスタム プロパティに変換します。コンポーネントのコンテキストに沿ったカスタム プロパティには、2 つの主なメリットがあります。まず、その値をコンポーネント内の複数のプロパティに適用できるため、CSS をより「DRY」にできます。


.n-tab-group-list::before {
  /* ... */
  padding-inline-start: var(--n-tab-group-padding);
}

.n-tab-group-list::after {
  /* ... */
  padding-inline-end: var(--n-tab-group-padding);
}
コンポーネント コード内の複数の場所で使用されているタブグループのパディングのコンテキスト カスタム プロパティ。

2 つ目は、コンポーネントの状態とバリエーションの変更が非常にクリーンになることです。ホバー状態やアクティブ状態、この場合はバリエーションのスタイルを設定するときに、すべてのプロパティを更新するために変更する必要があるのはカスタム プロパティだけです。


:host([padding="l"]) {
  --n-tab-group-padding: var(--n-space-l);
}
タブ コンポーネントのバリエーション。複数の更新ではなく、1 つのカスタム プロパティの更新を使用してパディングが変更されています。

しかし、最も強力なメリットは、コンポーネントにこれらのコンテキスト カスタム プロパティを定義すると、各コンポーネントに一種のカスタム CSS API が作成され、そのコンポーネントのユーザーが利用できるようになることです。


<nord-tab-group label="Title">
  <!-- ... -->
</nord-tab-group>

<style>
  nord-tab-group {
    --n-tab-group-padding: var(--n-space-xl);
  }
</style>
ページでタブグループ コンポーネントを使用し、パディングのカスタム プロパティをより大きなサイズに更新する。

上記の例は、セレクタを使用してコンテキスト カスタム プロパティが変更されたウェブ コンポーネントの 1 つを示しています。このアプローチ全体の結果として、ユーザーに十分なスタイルの柔軟性を提供しながら、実際のスタイルのほとんどをチェックできるコンポーネントが実現します。さらに、コンポーネント デベロッパーは、ユーザーが適用したスタイルをインターセプトできます。これらのプロパティのいずれかを調整または拡張する場合、ユーザーがコードを変更する必要はありません。

このアプローチは、デザイン システム コンポーネントの作成者である私たちだけでなく、これらのコンポーネントをプロダクトで使用する開発チームにとっても非常に強力です。

カスタム プロパティをさらに活用する

この記事の執筆時点では、これらのコンテキスト カスタム プロパティはドキュメントに記載されていませんが、より幅広い開発チームがこれらのプロパティを理解して活用できるように、今後記載する予定です。Google のコンポーネントは、マニフェスト ファイルとともに npm にパッケージ化されています。このファイルには、コンポーネントに関するすべての情報が含まれています。次に、ドキュメント サイトがデプロイされるときに、マニフェスト ファイルをデータとして使用します。これは、Eleventy とそのグローバル データ機能を使用して行われます。これらのコンテキスト カスタム プロパティをこのマニフェスト データ ファイルに含める予定です。

改善したいもう一つの点は、これらのコンテキスト カスタム プロパティが値を継承する方法です。たとえば、2 つの区切り線コンポーネントの色を調整する場合、セレクタを使用して両方のコンポーネントを具体的にターゲットにするか、style 属性を使用して要素にカスタム プロパティを直接適用する必要があります。これでも問題ないように思えますが、デベロッパーがこれらのスタイルをコンテナ要素やルートレベルで定義できれば、さらに便利です。


<nord-divider></nord-divider>

<section>
  <nord-divider></nord-divider>
   <!-- ... -->
</section>

<style>
  nord-divider {
    --n-divider-color: var(--n-color-status-danger);
  }

  section {
    padding: var(--n-space-s);
    background: var(--n-color-surface-raised);
  }
  
  section nord-divider {
    --n-divider-color: var(--n-color-status-success);
  }
</style>
2 つの異なる色処理が必要な、2 つのインスタンスの区切りコンポーネント。1 つはセクション内にネストされており、より具体的なセレクタに使用できますが、区切り線を具体的にターゲットにする必要があります。

カスタム プロパティ値をコンポーネントに直接設定する必要があるのは、コンポーネント ホスト セレクタを使用して同じ要素に定義しているためです。コンポーネントで直接使用するグローバル デザイン トークンは、この問題の影響を受けずにそのまま渡され、親要素でインターセプトすることもできます。両方の長所を活かすにはどうすればよいでしょうか。

非公開および公開のカスタム プロパティ

プライベート カスタム プロパティは、Lea Verou によってまとめられたものです。これは、コンポーネント自体のコンテキストに応じた「プライベート」カスタム プロパティですが、フォールバック付きの「パブリック」カスタム プロパティに設定されています。



:host {
  --_n-divider-color: var(--n-divider-color, var(--n-color-border));
  --_n-divider-size: var(--n-divider-size, 1px);
}

.n-divider {
  border-block-start: solid var(--_n-divider-size) var(--_n-divider-color);
  /* ... */
}
コンテキスト カスタム プロパティが調整された区切り線のウェブ コンポーネント CSS。内部 CSS がプライベート カスタム プロパティに依存するように調整されています。このプライベート カスタム プロパティは、フォールバック付きのパブリック カスタム プロパティに設定されています。

このようにコンテキスト カスタム プロパティを定義すると、グローバル トークン値の継承やコンポーネント コード全体での値の再利用など、以前に行っていたすべての処理を引き続き行うことができます。また、コンポーネントは、そのプロパティの新しい定義を自身または親要素に適切に継承します。


<nord-divider></nord-divider>

<section>
  <nord-divider></nord-divider>
   <!-- ... -->
</section>

<style>
  nord-divider {
    --n-divider-color: var(--n-color-status-danger);
  }

  section {
    padding: var(--n-space-s);
    background: var(--n-color-surface-raised);
    --n-divider-color: var(--n-color-status-success);
  }
</style>
2 つの区切り線が再び表示されますが、今回は区切り線のコンテキスト カスタム プロパティをセクション セレクタに追加することで、区切り線の色を変更できます。区切り線はこれを継承するため、よりクリーンで柔軟なコードを作成できます。

この方法は真に「プライベート」ではないという意見もあるかもしれませんが、私たちが懸念していた問題に対する、かなり洗練された解決策であると考えています。機会があれば、コンポーネントでこの問題に取り組み、開発チームがコンポーネントの使用をより細かく制御できるようにしながら、既存のガードレールのメリットも享受できるようにする予定です。

CSS カスタム プロパティでウェブ コンポーネントを使用する方法に関するこの分析情報がお役に立てば幸いです。ご意見をお聞かせください。また、これらの方法を独自の作業で使用する場合は、Twitter @DavidDarnes で私を見つけることができます。Nordhealth の Twitter アカウントは @NordhealthHQ です。また、このデザイン システムの構築と、このドキュメントで紹介した機能の実装に尽力したチームメンバーのアカウントは、@Viljamis、@WickyNilliams、@eric_habich です。