30
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

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

Last updated at Posted at 2024-06-15

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

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

画面の右下に、「キャンセル」と「次に進む」のボタンがその順番で並んでいる。両者にはそれぞれ異なるアイコンが設定されている。「キャンセル」ボタンは薄いグレーに黒文字、「次に進む」は濃い青に白文字のスタイル。

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

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

/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> に指定できる属性は、そのまま指定可能

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

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


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

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

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

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

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

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 変数を使ってさらに整理してみました。

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 か」というバリエーションによる色の違いを整理しました。

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

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

--_ から始める名前にする人もいるらしい 1

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

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

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


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

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

もう一点、

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

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

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

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 を見なくてもセレクタの意味が分かりやすい

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

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

30
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
30
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?