💾

Tailwind CSS + :where() で スタイル上書き可能なコンポーネント設計

2022/11/04に公開

コードは、Tailwind CSS を利用する前提で例示する。

いままで

UIコンポーネントの実装において、 margin などの外部に影響するスタイルについて、コンポーネントを呼び出す側で指定する実装をすることが多い。

SectionHead.jsx
import classNames from 'classnames'

export const SectionHead = ({ className, children }) => {
  return <h2 className={classNames('py-2 border-b text-lg', className)}>{children}</h2>
}
Page.jsx
const Page = () => {
  return <main>
    <section>
      <SectionHead className="mb-2">はじめに</SectionHead>
      <p>こんにちは!この記事では...</p>
    </section>
  </main>
}

なぜ分けるのか

SectionHead コンポーネントで mb-2 を指定した場合、別のセクションで mb-4 を指定したい場合に、 mb-4 のスタイルが適用される保証がない。
スタイルは、あとから定義されているほうが優先される (同じ詳細度の場合)。
SPA や SSG の場合、生成時やページ読み込みタイミングによって、スタイル定義の順番が変わることがよく発生する。

output.html
<!-- こう出力されると、「おわりに」 には margin-bottom: 16px が適用される -->
<style>
.mb-2 { margin-bottom: 8px; } /* 同じ詳細度 */
.mb-4 { margin-bottom: 16px; } /* 同じ詳細度かつ定義が後なので適用 */
</style>

<!-- こう出力されると、「おわりに」 には margin-bottom: 8px が適用されてしまう -->
<style>
.mb-4 { margin-bottom: 16px; } /* 同じ詳細度 */
.mb-2 { margin-bottom: 8px; } /* 同じ詳細度かつ定義が後なので適用 */
</style>

<main>
  <section>
    <h2 class="py-2 border-b text-lg mb-2">はじめに</h2>
    <p>こんにちは!この記事では...</p>
  </section>
  <section>
    <h2 class="py-2 border-b text-lg mb-2 mb-4">おわりに</h2>
    <p>ありがとうございました!</p>
  </section>
</main>

!important を利用するなどして詳細度を上げれば、margin-bottom: 16px を必ず適用することはできるが、詳細度の管理が大変なのでできれば使いたくない。気にしないならこれで解決するのでどうぞ。

このような事象を未然に防ぐ方法として、スタイルが内部で収まるか外部にも影響するかによって、スタイルを付与するタイミングを変えている。

課題

例示したセクションの見出しのmarginは、シンプルなデザインであれば基本的にはどこでも同じで、変わることのほうが少ない。
基本スタイルである mb-2 を毎回記述するのも冗長だし、メンテしづらい。

Page.jsx
const Page = () => {
  return <main>
    <section>
      <SectionHead className="mb-2">はじめに</SectionHead>
      <p>こんにちは!この記事では...</p>
    </section>
    <section>
      <SectionHead className="mb-2">アメリカの場合</SectionHead>
      <p>自由の女神が人気です</p>
    </section>
    <section>
      <SectionHead className="mb-2">フランスの場合</SectionHead>
      <p>エッフェル塔がありますね</p>
    </section>
    <section>
      <SectionHead className="mb-2">ドイツの場合</SectionHead>
      <p>ドイツは好きですよ</p>
    </section>
    <section>
      <SectionHead className="mb-4">おわりに</SectionHead>
      <p>ありがとうございました!</p>
    </section>
  </main>
}

解決策

:where() 擬似クラス関数を利用する (MDNでの解説)。

:where() 擬似クラス関数を利用し、確実に上書きができるデフォルトスタイルとして、コンポーネント側に margin を定義する。
:where() は、 :where(article, #about, .selector) h1 {} など、カンマ区切りで渡した複数のセレクターのいずれかに当てはまれば適用されるという、OR関数のような働きをする。
同様の働きをする擬似クラス関数に :is() もあるが、 :where() は詳細度が 0 になるという特徴もあり、これを利用する。

output.html
<!-- こう出力されると、「おわりに」 には margin-bottom: 16px が適用される -->
<style>
:where(.mb-2) { margin-bottom: 8px; } /* 詳細度が低い */
.mb-4 { margin-bottom: 16px; } /* 詳細度が高いので適用される */
</style>

<!-- こう出力されても、「おわりに」 には margin-bottom: 16px が適用される -->
<style>
.mb-4 { margin-bottom: 16px; } /* 詳細度が高い */
:where(.mb-2) { margin-bottom: 8px; } /* 定義が後だが、詳細度が低いので適用されない */
</style>

:where() は Chrome であれば v88 以上から使えるが、執筆時点で v106 なので、利用して全く問題ないだろう。

Tailwind CSS での実装方法

Tailwind CSS v3.2.1 時点で、:where() を扱う機能がない。
プラグインを作成する。4行で済む。

tailwind.config.js
const plugin = require('tailwindcss/plugin');

module.exports = {
  content: [],
  theme: {},
  plugins: [
    plugin(({ addVariant }) => {
      addVariant('where', ':where(&)');
    }),
  ],
};

これだけで、where:... class で :where(...) というセレクターが生成されるようになった。

SectionHead.jsx
import classNames from 'classnames'

export const SectionHead = ({ className, children }) => {
  return <h2 className={classNames('py-2 border-b text-lg where:mb-2', className)}>{children}</h2>
}
Page.jsx
const Page = () => {
  return <main>
    <section>
      {/* 指定せずとも mb-2 が適用される */}
      <h2>はじめに</h2>
      <p>こんにちは!この記事では...</p>
    </section>
    <section>
      {/* mb-4 が必ず適用される、はず */}
      <h2 class="mb-4">おわりに</h2>
      <p>ありがとうございました!</p>
    </section>
  </main>
}

Discussion