前書き: ボタンコンポーネントのスタイリングを題材に
こんな感じのボタンのコンポーネントを題材に、CSS 変数を使った CSS 記述の改善について考えてみました。
今回は React と CSS Modules を使用して実装していますが、フレームワーク等に依存せず応用できるような内容です。
この「キャンセル」「次に進む」ボタンは、Button コンポーネントを使って以下のように書いて設置されています。
<div className={style.buttons}>
<Button
type="button"
color="secondary"
startIcon={<FiRotateCcw size={18} />}
>
キャンセル
</Button>
<Button
type="submit"
color="primary"
startIcon={<FiCheckCircle size={18} />}
>
次に進む
</Button>
</div>
このボタンの仕様はざっくりと以下の通りです。
-
color
Prop は以下の2通りの文字列を指定可能-
"primary"
… 青い背景、白文字 -
"secondary"
… 薄灰色の背景、黒文字
-
-
startIcon
Prop として ReactNode を渡せる- 省略可。渡されたアイコンを、ラベルの左側に配置する
- つまり、「コンポジション」パターンです
-
type
など、<button>
に指定できる属性は、そのまま指定可能
この記事で提示するコードでは、本題のために必要な要素だけを実装しています。
disabled 時のスタイルや、細かなインタラクションなどはカバーしておらず、他にも漏れている点があるかもしれません。
本題の CSS のコードを見せる前に、React / TypeScript が書ける方に向けて、理解の補助になると思うので、 React のコンポーネント定義を見せておきます。
import type { ComponentPropsWithRef, FC, ReactNode } from "react";
import clsx from "clsx";
import styles from "./button.module.scss";
type Props = Omit<ComponentPropsWithRef<"button">, "color"> & {
color: "primary" | "secondary";
startIcon?: ReactNode;
};
export const Button: FC<Props> = ({ color, children, startIcon, ...props }) => {
return (
<button
{...props}
className={clsx(props.className, styles.root)}
data-color={color}
>
{startIcon && <span className={styles.icon}>{startIcon}</span>}
<span className={styles.text}>{children}</span>
</button>
);
};
CSS 変数を使わない書き方
Button コンポーネントのスタイルを、CSS 変数を使わずに書くと以下のようになります。
実際には Sass を使っていますが、おそらく CSS として不正にならない書き方にしています。
ただ、&
を使ったネストの記法は、最新でない iOS で解釈不能なので、 PostCSS か Sass
https://developer.mozilla.org/ja/docs/Web/CSS/Nesting_selector
すでに、
- バリエーション違い・状態による変化はネストで表す
- 例:
&:focus-visible
,&[data-color="..."]
- 例:
- ボタンの構成要素はネストを使わない
- 例:
.icon
,.text
- 例:
- どうしても子セレクタが必要なところは、ネストを使う
- 例:
.icon { & > :where(svg) {
- 例:
- そもそも、バリエーション・状態の変化のためのクラスを追加せず、data 属性を活用する
- 例:
&[data-color="..."]
- 例:
という形で、ネストを駆使して、同じクラス名を一度しか書かないようにしていますが、
乱用すると「ネストが深すぎて全体の構造が分からないよ~」となるので、乱用を避けています。
.root {
display: inline flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
border: none;
border-radius: 8px;
outline: none;
&:focus-visible {
outline: 2px solid #266db8;
outline-offset: 2px;
}
&[data-color="primary"] {
color: white;
background-color: #266db8;
&:hover {
background-color: #4284ca;
}
}
&[data-color="secondary"] {
color: #171717;
background-color: #f0f0f0;
&:hover {
background-color: #f7f7f7;
}
}
}
.icon {
margin-inline-start: -0.5rem;
& > :where(svg) {
display: block;
}
}
.text {
// ちょっとズレるので微調整しています。
// Biz UDP ゴシックだと、この値にするとしっくり来ますが、
// このコンポーネントでフォントは固定していないので、環境依存で変わります。
transform: translateY(0.05em);
}
CSS 変数を使って整理してみると
ここから、CSS 変数を使ってさらに整理してみました。
.root {
display: inline flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: bold;
+ color: var(--c-color);
cursor: pointer;
+ background-color: var(--c-bg-color);
border: none;
border-radius: 8px;
outline: none;
+ &:hover {
+ background-color: var(--c-hover-bg-color);
+ }
+
&:focus-visible {
outline: 2px solid #266db8;
outline-offset: 2px;
}
+ // フォールバックのための定義。
+ // ついでに「このコンポーネント内では、こんな CSS 変数を使いますよ」というカタログになっている
+ --c-bg-color: white;
+ --c-color: #171717;
+ --c-hover-bg-color: #f0f0f0;
&[data-color="primary"] {
- color: white;
- background-color: #266db8;
-
- &:hover {
- background-color: #4284ca;
- }
+ --c-bg-color: #266db8;
+ --c-color: white;
+ --c-hover-bg-color: #4284ca;
}
&[data-color="secondary"] {
- color: #171717;
- background-color: #f0f0f0;
-
- &:hover {
- background-color: #f7f7f7;
- }
+ --c-bg-color: #f0f0f0;
+ --c-color: #171717;
+ --c-hover-bg-color: #f7f7f7;
}
}
.icon {
margin-inline-start: -0.5rem;
& > :where(svg) {
display: block;
}
}
.text {
transform: translateY(0.05em);
}
ここでは、 --c-bg-color
, --c-color
, --c-hover-bg-color
という3つの CSS 変数を導入することで、 「color の値が primary か secondary か」というバリエーションによる色の違いを整理しました。
CSS 変数名が --c-
から始まっているのは、これらが《ページ全体でトークンを共有するため》ではなく、《コンポーネント内部の記述を整理するため》であると明示して、両者の衝突を避けるためです。(c は Component の頭文字から)
stylelint を使用して、css 変数名をケバブケースに強制しているので、そのルール内で考えたものです。
(--_
から始める名前にする人もいるらしい 1)
-
color=primary
- 背景色
#266db8
- 文字色
white
- ホバー時の背景色
#4284ca
- 背景色
-
color=secondary
- 背景色
#f0f0f0
- 文字色
#171717
- ホバー時の背景色
#f7f7f7
- 背景色
という、色のバリエーションについての宣言が、ギュッと近くにまとめて記述されていることが、下記の AFTER のコードを見ると一目瞭然だと思います。
コード内で近くにまとまっていることは、文字の打ち間違いに気づきやすいのも利点です。
背景色、文字色については、あまり旨味は少ないですが、ホバー時の背景色が「宣言的(?)」になったと思います。
BEFORE では :hover
が各バリエーションごとに書かれていましたが、 AFTER では単純に文字数が減り、ネストが一段階減ったことで、全体の構造を理解しやすくなったと思います。
もう一点、
- 「このボタンは、ホバー時に色が変わる」というバリエーション非依存の性質と
- バリエーション依存で決定される「ホバー時の背景色」
の記述が分離されることで、かえってコードの意図が理解しやすくなっている、と思います。
少し回りくどい書き方にも見えるかもしれませんが、十分なメリットがあるのではないでしょうか。
.root {
display: inline flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: bold;
color: var(--c-color);
cursor: pointer;
background-color: var(--c-bg-color);
border: none;
border-radius: 8px;
outline: none;
&:hover {
background-color: var(--c-hover-bg-color);
}
&:focus-visible {
outline: 2px solid #266db8;
outline-offset: 2px;
}
// フォールバックのための定義。
// ついでに「このコンポーネント内では、こんな CSS 変数を使いますよ」というカタログになっている
--c-bg-color: white;
--c-color: #171717;
--c-hover-bg-color: #f0f0f0;
&[data-color="primary"] {
--c-bg-color: #266db8;
--c-color: white;
--c-hover-bg-color: #4284ca;
}
&[data-color="secondary"] {
--c-bg-color: #f0f0f0;
--c-color: #171717;
--c-hover-bg-color: #f7f7f7;
}
}
.icon {
margin-inline-start: -0.5rem;
& > :where(svg) {
display: block;
}
}
.text {
transform: translateY(0.05em);
}
まとめ
今回お見せした Button コンポーネントの例から、CSS 変数を使うことで
- 「状態」と「バリエーション」の記述が、ゴチャ混ぜにならないよう、キレイに分離・整理できる
- バリエーションについての記述が近くにまとまるので、保守性が高い
ということが分かったと思います。
また、本題とは異なるので、説明を省きましたが、
- (JS 側)クラス名の連結には
clsx()
を使って、凡ミスをなくす - data 属性を使う
- JS 側から見たら、クラス名の切り替えよりも条件分岐が単純になるし、意図が分かりやすい
- CSS を見るだけで、JS を見なくてもセレクタの意味が分かりやすい
という点についても、知らなかったなら知っておいてください!