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

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

David Darnes
David Darnes

Nordhealth のシニア フロントエンド デベロッパーを務める Dave です。私は Google のデザイン システム Nord の設計と開発を担当しています。これには、コンポーネント ライブラリの Web Components を構築することも含まれます。本日は、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 Component をページで使用する。

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

ネイティブ HTML 要素と同様に、ウェブ コンポーネントにも Shadow DOM があります。Shadow DOM は、要素内のノードの隠れたツリーです。これを視覚化するには、ウェブ インスペクタを開いて、[Show Shadow DOM tree] オプションをオンにします。完了したら、インスペクタでネイティブの入力要素を確認してみましょう。その入力を開いて、その入力内のすべての要素を表示するオプションが表示されます。いずれかのウェブ コンポーネントを使用して、Google のカスタム入力コンポーネントを調べて、Shadow DOM を確認してみてください。

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

Shadow DOM の利点(または、視点によっては欠点)の 1 つに、スタイルのカプセル化があります。Web Component 内に CSS を記述した場合、そのスタイルがメインページなどの要素に影響を与えたり、影響したりすることはありません。CSS はコンポーネント内に完全に収められます。また、メインページまたは親の Web Component 用に記述された CSS を Web Component に漏洩させることはできません。

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


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

しかし、Web Component を使用するユーザーに、正当な理由がある特定のスタイルを変更する必要が生じる場合はどうすればよいでしょうか。たとえば、文脈に合わせてコントラストを強くする必要があるテキスト行があったり、枠線を太くしたりする必要があるケースなどが考えられます。コンポーネントにスタイルを適用できない場合、スタイル オプションのロックを解除するにはどうすればよいですか。

そこで役立つのが 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 Component のカスタム プロパティ

デザイン システムのコンポーネントを開発するときは常に、その 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);
  /* ... */
}
コンポーネントの Shadow ルートで定義され、コンポーネント スタイルで使用されるカスタム プロパティ。デザイン トークンのリストにあるカスタム プロパティも使用されます。

また、トークンには含まれていないコンポーネントに固有の値を抽象化して、コンテキストに応じたカスタム プロパティに変換します。コンポーネントのコンテキストに基づくカスタム プロパティには、主に 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 を作成することです。そのコンポーネントのユーザーは、この API を利用できます。


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

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

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

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

カスタム プロパティの詳細

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

もう 1 つ改善の余地があるのは、コンテキストに応じたカスタム プロパティが値を継承する方法です。たとえば現在、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 とコンテキストに応じたカスタム プロパティ。この 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 つの分割線も同様にしますが、今回は分割線のコンテキストに応じたカスタム プロパティをセクション セレクタに追加することで、分割線の色を変更できます。分割線がこれを継承し、よりクリーンで柔軟なコードを生成します。

この方法は真に「プライベート」ではないと言われるかもしれませんが、それでも、懸念されていた問題に対するかなり洗練された解決策であると考えています。機会があり次第、コンポーネントでこれに取り組む予定です。これにより、開発チームが既存のガードレールを活用しながら、コンポーネントの使用状況をより細かく制御できるようになります。

Web Components における CSS カスタム プロパティの使用例がお役に立てば幸いです。ご意見やご感想をお寄せください。また、ご自身の作業でこれらの方法を使用することに決めた場合は、Twitter で @DavidDarnes にご連絡ください。また、Twitter で Nordhealth の @NordhealthHQ をご確認いただけます。また、このデザイン システムの統合と、この記事(@Viljamis@WickyNilliams@eric_habich)で紹介されている機能の実行に尽力してくれた他のチームメンバーもいます。

ヒーロー画像(作成者: Dan Cristian P 任意