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

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

David Darnes
David Darnes

Nordhealth でシニア フロントエンド デベロッパーを務める Dave です。私は、コンポーネント ライブラリの Web コンポーネントの構築など、デザイン システム Nord の設計と開発に携わっています。今回は、CSS カスタム プロパティを使用して Web Components のスタイル設定に関する問題を解決した方法と、デザインシステムやコンポーネント ライブラリでカスタム プロパティを使用するメリットについて説明します。

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

Google のウェブ コンポーネントの構築には 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 で記述されたウェブ コンポーネント

しかし、Web Components の最も魅力的な点は、既存の JavaScript フレームワークのほとんど、あるいはまったくフレームワークなしで機能する点です。メインの JavaScript パッケージがページで参照されると、ウェブ コンポーネントの使用はネイティブ HTML 要素を使用する場合とよく似ています。ネイティブ HTML 要素ではないことを示す唯一の証拠は、タグ内に一貫したハイフンを使用することです。これは、これがウェブ コンポーネントであることをブラウザに示すための標準です。


// TODO: DevSite - Code sample removed as it used inline event handlers
上記で作成した Web コンポーネントをページで使用。

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

ネイティブ HTML 要素とウェブ コンポーネントでは、Shadow DOM とほぼ同じです。Shadow DOM は、要素内のノードの非表示のツリーです。これを視覚化するには、ウェブ インスペクタを開いて、[Shadow DOM ツリーを表示] オプションをオンにします。それが終わったら、インスペクタでネイティブ入力要素を確認してみましょう。その入力を開くと、その入力に含まれるすべての要素を確認できるようになります。Google のウェブ コンポーネントで試すこともできます。カスタム入力コンポーネントを調べて、Shadow DOM を確認してみてください。

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

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

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


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

ただし、Web コンポーネントを使用しているユーザーが特定のスタイルを変更する正当な理由がある場合はどうすればよいでしょうか。文脈上コントラストを強くする必要があるテキスト行がある場合や、枠線を太くする必要がある場合があります。コンポーネントにスタイルを適用できない場合、それらのスタイル設定オプションを利用するにはどうすればよいですか。

このような場合に役立つのが 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 Web コンポーネントのカスタムプロパティ

デザイン システムのコンポーネントを開発するときは常に、その 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 をより「ドライ」にできます。


.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>
ページでタブグループ コンポーネントを使用し、パディングのカスタム プロパティをより大きなサイズに更新。

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

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

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

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

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


<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 がプライベート カスタム プロパティに依存するように調整されたコンテキスト カスタム プロパティを含む分割線の Web コンポーネント 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 つの分割線ですが、今回は分割線のコンテキストに応じたカスタム プロパティをセクション セレクタに追加することで、分割線の色を変更できます。ディバイダがそれを継承し、よりクリーンかつ柔軟なコードが生成されます。

この方法は厳密には「非公開」ではないと主張されるかもしれませんが、懸念していた問題に対するエレガントな解決策であると考えております。機会があれば、コンポーネントでこの課題に取り組む予定です。これにより、開発チームはコンポーネントの使用をより細かく管理しながら、Google が設けているガードレールのメリットも享受できます。

Google が CSS カスタム プロパティで Web Components を使用する方法に関するこの分析がお役に立てば幸いです。ご意見、ご感想をぜひお聞かせください。また、これらの手法をご自身の仕事で利用する場合は、Twitter(@DavidDarnes)までお知らせください。Nordhealth の Twitter アカウント(@NordhealthHQ)や、このデザイン システムの統合と、この記事で説明した機能の実装に尽力したチームメンバー(@Viljamis@WickyNilliams@eric_habich)もフォローしてください。

ヒーロー画像: Dan Cristian Pădureț