【CSS】「状態変化」と「バリエーション違い」はCSS変数で整理できる

## 前書き: ボタンコンポーネントのスタイリングを題材に

こんな感じのボタンのコンポーネントを題材に、CSS 変数を使った CSS 記述の改善について考えてみました。

![画面の右下に、「キャンセル」と「次に進む」のボタンがその順番で並んでいる。両者にはそれぞれ異なるアイコンが設定されている。「キャンセル」ボタンは薄いグレーに黒文字、「次に進む」は濃い青に白文字のスタイル。](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/234029/84937847-2c0b-45fb-5b6b-7d3bf97ca2a8.png)

:::note info
今回は React と CSS Modules を使用して実装していますが、フレームワーク等に依存せず応用できるような内容です。
:::


この「キャンセル」「次に進む」ボタンは、Button コンポーネントを使って以下のように書いて設置されています。

```tsx:/apps/hoge/page.tsx の一部
<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>` に指定できる属性は、そのまま指定可能

:::note warn
この記事で提示するコードでは、本題のために必要な要素だけを実装しています。

disabled 時のスタイルや、細かなインタラクションなどはカバーしておらず、他にも漏れている点があるかもしれません。
:::


---

本題の CSS のコードを見せる前に、React / TypeScript が書ける方に向けて、理解の補助になると思うので、 React のコンポーネント定義を見せておきます。

```tsx:/apps/_ui/button.tsx
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 変数を使わずに書くと以下のようになります。

:::note info
実際には 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="..."]`

という形で、ネストを駆使して、同じクラス名を一度しか書かないようにしていますが、

乱用すると「ネストが深すぎて全体の構造が分からないよ～」となるので、乱用を避けています。

```scss:BEFORE /apps/_ui/button.module.scss
.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 変数を使ってさらに整理してみました。

```diff_scss:BEFORE -> AFTER /apps/_ui/button.module.scss
  .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 か」というバリエーションによる色の違いを整理しました。

:::note info
CSS 変数名が `--c-` から始まっているのは、これらが《ページ全体でトークンを共有するため》ではなく、《コンポーネント内部の記述を整理するため》であると明示して、両者の衝突を避けるためです。（c は Component の頭文字から）

stylelint を使用して、css 変数名をケバブケースに強制しているので、そのルール内で考えたものです。

（`--_` から始める名前にする人もいるらしい [^1]）

[^1]: https://x.com/tak_dcxi/status/1800752389525561346, https://x.com/tak_dcxi/status/1800750137096225049
:::


- **color=primary** 
  - 背景色 `#266db8`
  - 文字色 `white`
  - ホバー時の背景色 `#4284ca`
- **color=secondary** 
  - 背景色 `#f0f0f0`
  - 文字色 `#171717`
  - ホバー時の背景色 `#f7f7f7`

という、色のバリエーションについての宣言が、ギュッと近くにまとめて記述されていることが、下記の AFTER のコードを見ると一目瞭然だと思います。

コード内で近くにまとまっていることは、文字の打ち間違いに気づきやすいのも利点です。

---
 
背景色、文字色については、あまり旨味は少ないですが、ホバー時の背景色が「宣言的(?)」になったと思います。

BEFORE では `:hover` が各バリエーションごとに書かれていましたが、 AFTER では単純に文字数が減り、ネストが一段階減ったことで、全体の構造を理解しやすくなったと思います。

もう一点、

- 「このボタンは、ホバー時に色が変わる」というバリエーション非依存の性質と
- バリエーション依存で決定される「ホバー時の背景色」

の記述が分離されることで、かえってコードの意図が理解しやすくなっている、と思います。

少し回りくどい書き方にも見えるかもしれませんが、十分なメリットがあるのではないでしょうか。


 
```scss:AFTER /apps/_ui/button.module.scss
.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 を見なくてもセレクタの意味が分かりやすい


という点についても、知らなかったなら知っておいてください！