Panda CSSで作ったUIコンポーネントライブラリのスタイルが上書きされたり上書きされなかったりした話
はじめに
Panda CSS を用いた UI コンポーネントライブラリの開発において、Cascade Layer と CSS の詳細度に対する理解が不足していたため、スタイルが意図せず上書きされたり、逆に上書きできなくなったりする問題が発生しました。本記事では、これらの問題の詳細と、それに対する解決策を備忘録としてまとめています。
この記事は、Panda CSS に特化した内容というよりも、CSS の詳細度に注意を払うことの重要性に焦点を当てています。CSS の詳細度に関する知識を深め、同様の問題を回避するためのヒントを得たい方にとって、有益な情報を提供できれば幸いです。
今回の問題が発生する原因となった背景
ライブラリが提供するコンポーネントにはある程度のスタイリングの自由度を設けることにしました。これにより、各プロダクトが独自のスタイルを適用しつつ、共通のコンポーネントを活用できるようにしています。これによりライブラリの利用を推進できます。
具体的な方法として、プロダクトが独自のスタイルを当てるために、クラス名を受け取ってそれをルート要素のクラス名の末尾に付与するアプローチを採用しました。これにより、クラス名の順序によって詳細度の強さを調整し、コンポーネントのスタイルを上書きできると考えました。この方法は、各プロダクトが必要に応じてスタイルをカスタマイズできる柔軟性を提供します。
以下は、その実装例です。
export function Component({className}) {
const classes = componentRecipe(); // Panda CSS の Define Slot Recipe によって生成された関数。各スロットごとのクラス名を返却する。
// ...
return (
<div className={cx(classes.root, className)}> // `cx` は Panda CSS が提供しているクラス名を結合する関数
{/*コンポーネントの内容*/}
</div>
);
}
この例では、外部から渡されたクラス名を Panda CSS によって使用して生成されたクラス名の後ろに結合しています。これにより、プロダクト側で独自のスタイルを適用できるようになっています。
しかし、実際にプロダクトに導入してみると、スタイルの上書きに関して 2 つの問題が発生しました。このことから、上記の方法だけでは期待通りの結果を得ることができないことが判明しました。以下では、それらの問題と、それに対して取った解決策について詳しく説明します。
問題①: Cascade Layerがプロダクト側のリセットCSSに上書きされてしまう
最初に直面した課題は、Cascade Layer の特性によるものでした。Cascade Layer は、レイヤー外のクラスに対して詳細度で負けるという特性があります。このため、リセット CSS などの本来一番詳細度を下げるべきスタイルまでもが優先されてしまいます。この問題により、意図しないスタイルの上書きが発生し、期待通りのデザインが適用されないことがありました。
以下のコードは、ライブラリで提供しているスタイルとプロダクト側で定義しているスタイルの例です。この例では、layer に含まれたクラスと、layer に含まれないリセット CSS のクラス、そして同じく layer に含まれない通常のスタイリング用のクラスを示しています。
<!-- 実際にレンダリングされるHTML -->
<button class="library-button custom-style-button">ボタン</button>
/*ライブラリが提供しているクラス*/
@layer recipes {
.library-button {
color: black;
background-color: lightgray;
}
}
/*プロダクト側のリセットCSSの定義(layerに含まれない)*/
button {
margin: 0;
padding: 0;
color: gray;
}
/*プロダクト側のクラス定義(layerに含まれない)*/
.custom-style-button {
background-color: white;
}
この例で期待される button 要素のスタイルは、color:black
、background-color: white
です。
しかし、実際は color:gray
、background-color: white
となってしまいます。
通常リセット CSS は要素を指定するスタイルのため詳細度は低く、クラスの方が優先されます。
ところがこの例では、.library-button
クラスが Cascade Layer に含まれているため、リセット CSS の button 要素に対するスタイルや .custom-style
のような layer に含まれないスタイルに対して詳細度で負けてしまいます。これにより、意図しないスタイルの上書きが発生する可能性があります。
問題①の解決策
この課題に対処するために、Panda CSS の polyfill オプションを活用しました。これは PostCSS Cascade Layers プラグインを利用して、Cascade Layer のスタイルの適用順序を :not(#\#)
セレクタを使って擬似的に模倣する方法です。
なぜこのような模倣ができるのでしょうか。
それを理解するために、まず CSS の詳細度の基本を簡単に確認します。
詳細度は通常、以下のように計算されます。
- ID セレクタ:1 ポイント
- クラス、属性セレクタ、擬似クラス:1 ポイント
- 要素セレクタ、擬似要素:1 ポイント
このポイントを左から順に 1-1-1
の様に表現します。
最終的にそれぞれのポイントを左から順に並べて高い順に優先度をつけていきます。
例えば、詳細度が 1-0-0
のクラス A と 0-2-1
のクラス B があれば、クラス A の方が詳細度が高いということになります。
また :not()
、is()
および has()
擬似クラスは、擬似クラス自体は詳細度に影響を与えません。しかし、括弧内のセレクタの詳細度をそのまま引き継ぎます。つまり :not(#\#)
のように ID セレクタを含む場合、ID セレクタの詳細度が加算されます。
:not(#\#)
は ID セレクタを含んでいるため、1-0-0
の詳細度が追加されます。これにより、通常のクラスセレクタや要素セレクタよりも高い詳細度を持つことになります。結果として、:not(#\#)
を含むセレクタは、他の多くのセレクタよりも優先されることになります。
この仕組みにより、PostCSS Cascade Layers は :not(#\#)
セレクタを利用することで擬似的に Cascade Layer のスタイルの適用順序の管理を模倣します。
下記が実際に polyfill を適用したライブラリ側のスタイルです。
先ほどの例に出たその他のスタイルも含め、詳細度を記載します。
/*ライブラリが提供しているクラス*/
/*詳細度: 1-1-0 (クラス、not内のIDセレクタ)*/
.library-button:not(#\#) {
color: black;
background-color: lightgray;
}
/*プロダクト側のリセットCSSの定義*/
/*詳細度: 0-0-1 (要素セレクタ)*/
button {
margin: 0;
padding: 0;
color: gray;
}
/*プロダクト側のクラス定義*/
/*詳細度: 0-1-0 (クラス)*/
.custom-style-button {
background-color: white;
}
以上の詳細度を比較すると、ライブラリが提供しているクラスの詳細度 1-1-0
は、リセット CSS の 0-0-1
を上回っています。
そのため、リセット CSS によって意図せずスタイルを上書きされる事象を解決できました。
問題②:プロダクト側で定義したクラスでコンポーネントのスタイルを上書きできない
さて、一度は上記のように修正して問題が解決したと思っていた矢先、新たな問題が発生しました。
今度はプロダクト側の定義されたクラスを使ってコンポーネントのスタイルを上書きできなくなりました。
勘のいい皆様はもうお気づきでしょう。その通りです。
先ほどの例をみると、ライブラリが提供しているクラスの詳細度 1-1-0
は、プロダクト側の詳細度 0-1-0
も上回ってしまっています。
通常クラス同士、つまり 2 つ目のポイントが同じであれば、後に記載されたクラスが優先されます。
しかし今回の場合は :not(#\#)
セレクタが付与されることで、セレクタなしのクラスよりも詳細度が高くなってしまったのです。
問題②の解決策
この問題を解決するにあたって、今回は Panda CSS の polyfill のようなオプションは見つかりませんでした。
Panda CSS の Discussion に質問するなどして、いくつかの解決策を導き出しました。
最終的に提供する CSS ファイルから :not(#\#)
セレクタを削除する方法を選びました。
これにより、ライブラリで提供しているクラスとプロダクト側のクラス定義の詳細度は基本的に一致し、CSS の詳細度は基本的にクラス名が付与された順番に依存するようになりました。
リセット CSS の詳細度は引き続き上回っているため、意図しない上書きを避けることもできています。
/*ライブラリが提供しているクラス*/
/*詳細度: 0-1-0*/
.library-button {
color: black;
background-color: lightgray;
}
/*プロダクト側のリセットCSSの定義*/
/*詳細度: 0-0-1*/
button {
margin: 0;
padding: 0;
color: gray;
}
/*プロダクト側のクラス定義*/
/*詳細度: 0-1-0*/
.custom-style-button {
background-color: white;
}
これには Panda Integration Hooks を利用しました。
Panda Integration Hooks は、Panda CSS におけるカスタマイズと拡張性を提供するための仕組みです。これらのフックを利用することで、開発者は Panda CSS のビルドプロセスやスタイル生成の各ステージに介入し、特定の処理を追加したり、既存の動作を変更したりできます。
ここでは CSS ファイルを生成するコマンドが完了した後にその内容に対して介入する cssgen:done
hooks を利用しています。
下記は実際のコードです。
export default defineConfig({
// ...その他の設定
hooks: {
'cssgen:done': (args) => {// args.contentには生成されるCSSファイルが文字列で格納されています。
return args.content.replace(/:not\(#\\#\)/g, '');
},
},
});
cssgen コマンドの結果に対して、シンプルに :not\(#\#)
を削除するコードを追加しました。これで生成された CSS ファイルからは :not\(#\#)
が消えます。
その他の解決策
ここでは他に解決策として考えられる方法記載します。今回は選択しませんでしたが、特に 2 つ目の方法は状況によっては今回選択したものより理想的な可能性があります。
特に本題と関係ないため、気になる方だけ読んでください。
その他の解決策
インラインスタイルを設定できるプロパティを提供する方法
コンポーネントに CSSProperties 型の値を受け取るプロパティを追加し、ルート要素のインラインスタイルに適用する方法です。インラインスタイルを利用することで詳細度は !important
を除き最強になります。これによりコンポーネントで定義されているスタイルを上書きできます。
しかしこの方法では、Tailwind CSS のようにクラス名を渡すスタイリングライブラリを使ってスタイルを上書きできません。これではプロダクト側はスタイルがしづらくなり、ライブラリの利用の障壁になる可能性がありました。そのため、この方法は断念しました。
export function Component({overrideStyles}) {
// ...
return (
<div style={overrideStyles}>
{/*コンポーネントの内容*/}
</div>
);
}
許容するスタイルのみをプロパティとして提供する方法
コンポーネントで許容するスタイルをプロパティとして提供する方法です。プロダクト開発者は決められたスタイルのみを上書きできるため、自由度は下がりますが、スタイルが大きく崩れることがなくなります。これによりデザインの統一性はなるべく担保しつつ、プロダクトごとのスタイルを適用できます。
しかしこの方法ではコンポーネントごとにどのプロパティを受け取るかを決める必要があります。必要以上に提供してしまうと上記のメリットを失い、逆に少なく提供するとライブラリの利便性を損ねます。これらを管理するコストが高くなると判断しました。
また上の方法と同じくプロダクト側のスタイリングライブラリを使ってスタイルを上書きできません。
以上によりこの方法も断念しました。
export function Component({height, width}) {
// ...
return (
<div style={{height, width}}>
{/*コンポーネントの内容*/}
</div>
);
}
結果と今後の展望
今回の取り組みにより、2 つの主要な問題を解決できました。まず、Panda CSS の Cascade Layer がプロダクト側のリセット CSS に上書きされる問題については、:not(#\#)
セレクタを用いた polyfill を適用することで、意図しないスタイルの上書きを防ぐことができました。次に、プロダクト側で定義したクラスでコンポーネントのスタイルを上書きできない問題については、Panda Integration Hooks を活用して、生成された CSS から :not(#\#)
セレクタを削除することで解決しました。
これらの解決策を通じて、予定通りプロダクトごとのスタイリングの自由度を確保しつつ、共通コンポーネントを提供できました。
しかし、このプロセスを通じて、CSS の詳細度に関する理解が乏しかったことを痛感しました。Cascade Layer を含む詳細度の計算や適用順序の理解が不足していたために、思わぬ問題二度も引き起こしてしまいました。
また、UI コンポーネントライブラリを提供することの難しさも改めて実感しました。通常のアプリケーション開発とは異なり、ライブラリ開発では、利用するプロダクトの環境に左右されることが多くあります。異なるプロダクト間でのデザインの一貫性を保ちつつ、各プロダクトの個別のニーズに対応することは容易ではありません。
今後は、より理想的な解決策を模索し、より良いライブラリを提供できるように改善していきたいと考えています。
Discussion