Tailwind全盛期に、あえて「素のCSS変数」でデザインシステムを作る

日頃からTailwind CSSにはお世話になっていますが、中規模以上のプロジェクトを経験する中で、Tailwindだけに頼ると後々の保守が大変になるケースに何度も直面してきました。

Tailwindを活かしつつも、あえて素のCSS変数を中心に、デザインシステムの基盤を構築してみたときに、実際に出くわした課題や解決方法などの記録をまとめます。


Tailwind configが膨張してくる

開発初期の段階では、tailwind.config.jsにカラーパレットをまとめて記述する方式で問題ありませんでした。

// tailwind.config.js
module.exports = {
  theme: {
    colors: {
      primary: '#0066FF',
      secondary: '#FF6B35',
      // ... その他の色が延々と続く
    }
  }
}

でもコンポーネント数が500個を超えて、デザイン修正が週に2〜3回のペースで入るようになると、設定ファイルの色コード(生の値)を変更するたびにビルドが走るという流れが、足かせになり始めました。

特に困ったのがダークモードへの対応。ライトモード用とダークモード用で色を分けないといけないのに、それをTailwindの設定側で管理しようとすると、微調整のたびにコードを編集してビルドを待つという作業が何度も繰り返されます。設定ファイルの見直しの度に、メンテナンスがどんどん面倒に。


CSS変数とTailwindを組み合わせる

CSS変数の強みは、JavaScriptからランタイムで値をさっと変更できるところで、ビルドなしに色が切り替わるので、LocalStorageと組み合わせてユーザー設定を保存、復元するのも簡単になります。

:root {
  --color-primary: #0066FF;
  --color-secondary: #FF6B35;
  --color-danger: #FF3333;
}

/* ダークモード時の設定 */
[data-theme="dark"] {
  --color-primary: #4D94FF;
  --color-secondary: #FFB366;
  --color-danger: #FF6666;
}

動的クラス生成でスタイルが消える

CSS変数を使う=Tailwind CSSをやめるという話ではなく、両方を組み合わせるのが現実的なやり方です。

最初、bg-[var(--color-primary)]みたいにJIT記法で変数を直接埋め込めば、設定ファイルを触らずに済むと考えてました。ローカルではちゃんと動くんですが、一部のコンポーネントでクラス名を文字列結合で動的に生成していたんです(例えばbg-[var(--color-${themeColor})]みたいな)。でも本番ビルド時にTailwindの静的解析にひっかからず、該当するスタイルが削除されてしまい、背景が透明になるという問題が発生しました。

この失敗を経て、クラス名の動的結合はやめて、完全な文字列で書くようにしました。コードの可読性も高めるために、tailwind.config.js側でCSS変数を直接参照させる設計で統一することに。

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: 'var(--color-primary)',
        secondary: 'var(--color-secondary)',
        danger: 'var(--color-danger)',
        gray: {
          50: 'var(--color-gray-50)',
          100: 'var(--color-gray-100)',
        }
      },
      fontSize: {
        lg: 'var(--font-size-lg)',
      }
    }
  }
}

Tailwindのクラス指定の便利さはそのままで、実際にはCSS変数が背後で動いているので、ランタイムでテーマを切り替えることができるようになります。さらにクラス名を動的に組み立てず、完全な文字列としてコード内に残す形にしたんで(詳細は後述のTypeScript運用を参照)、ビルド時にパージされるリスクも回避できました。


FOUC対策と、Safariでの不具合

テーマ切り替え自体の実装は難しくないんですが、画面が一瞬チラつく「FOUC」に悩まされました。CSRでテーマ属性を付与しようとすると、HTMLが解析されてからJavaScriptが動く前に、ブラウザがスタイル適用前のデフォルト(ライトモード)を一瞬だけ描画してしまうのです。

厄介なことにprefers-color-schemeメディアクエリとカスタム属性の優先順位がブラウザによって異なるらしく、Safariでは特に「OS側はダークモード設定なのに、アプリが一瞬ライトモードで表示される」というちぐはぐなことが起こってました。

解決法として、HTMLの<head>内にレンダリングを制御するスクリプトを埋め込みました。

<head>
  <meta charset="UTF-8" />
  <script>
    // LocalStorage設定とOS設定の両方をチェック
    const getInitialTheme = () => {
      const local = localStorage.getItem('theme');
      if (local) return local;
      return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    };
    document.documentElement.setAttribute('data-theme', getInitialTheme());
  </script>
</head>

この方法なら同期的に実行されるため、スタイル適用前に変な描画が起こるのを防げられます。Safariを含む主要ブラウザで、テーマ切り替え時のフラッシュがちゃんと消えました。


運用とデバッグのコツ

TypeScriptで型安全性を確保する

HTML側でbg-primaryみたいなTailwindクラスを指定するときに、うっかりクラス名のタイポがあったり見落としたりすると、本番環境で特定のスタイルが反映されず、レイアウトが崩れる原因になります。そこで、この定義ファイル自体もTailwindのビルド対象(content)に含めた上で、クラス名をオブジェクトとして管理するアプローチを取りました。

// src/tokens/colors.ts
export const bgColors = {
  primary: 'bg-primary',
  secondary: 'bg-secondary',
  danger: 'bg-danger',
} as const;

export type BgColorToken = keyof typeof bgColors;

HTML側からはclassName={bgColors.primary}のように呼び出します。定義ファイル内に完全な文字列として残っているため、パージされるリスクもありません。

型安全性を保ちつつも、すべての変数をリスト化するのではなく、よく使う主要なトークンだけを型定義に切り出すという運用にしています。完全に網羅するんだとしたら、定義ファイル自体の更新コストが跳ね上がっちゃうので、バランスが大事です。

命名規則を統一してDebugを効率化

デザインツールから吐き出されたJSONトークンとCSS変数の対応をスクリプトで同期するとき、接頭辞のルールを固定することで、ユーティリティクラスのバッティングや変数名がバラバラになるのを防いでいます。

:root {
  /* パターン: --category-name */
  --color-primary: #0066FF;
  --color-secondary: #FF6B35;
  /* ... */
}

ですが、CSS変数の導入によって、DevTools上にはvar(--color-primary)といったトークン名がずらっと並ぶようになります。このスタイルはTailwindのどのクラスから適用されているんだろう?っていう追跡が、ちょっと直感的でなくなってしまうのはあります。なので、チーム内ではDevToolsの「Computed」パネルを活用して、どのCSSプロパティから変数が注入されているかを素早く特定するデバッグ方法を共有するのがよさそうです。

Storybookでトークン可視化

CSS変数のトークンを視覚的に表現して、チーム内で共有することも大切です。

// stories/DesignTokens.stories.jsx
export const AllColors = () => (
  // ... (グリッドレイアウトでトークンを表示)
);

Storybookに設定しておけば、デザイナーと開発者でいつでもトークンの状態を確認できるので、齟齬が減ります。


今回における役割分担

実装を進めるときに気づいたのは、「Tailwind CSSはスタイルの適用(クラス名)を担当し、CSS変数はデザイン値(テーマの動的切り替えなど)の管理を担当する」という役割分担だと保守コストが低く抑えられるということです。

コンポーネントが100個くらいの小規模なら、Tailwindだけで事足りると思いますが、動的なテーマ切り替えが必要になったり、ビルド時間の短縮が求められていれば、CSS変数をベースにした構成への移行をおすすめできます。