Radix Colors 導入:デザイントークン化と Stylelint による静的検証

はじめに

こんにちは。DTダイナミクスでフロントエンドの切削サービスの開発をしております、朱です。 本記事では、色定義が散らばっていた状態からデザインシステム化の足がかりとしてカラートークンを整備した背景とその内容をまとめました。 そして運用で起きやすいタイポ/未定義トークン混入に対し、トークンの整合性(定義済みのみが使われる状態)を保つために試したことについても共有します。

背景:なぜ今「トークン化」なのか

UIの色は、プロジェクトが育つほど「定義の置き場所」が増えがちです。
HEXの直書き、SCSSのカスタム変数、コンポーネント固有の色が混在してくると、変更時に「どこで使われているか」という影響範囲の調査に追われ、結果として「探索コスト」が膨れ上がってしまいます。

  • 色の定義が分散すると、修正が“変更”ではなく“発掘”になる
    変更のたびに検索 → 例外 → 上書きが増え、メンテナンスが難しくなる。
  • デザイン/開発で“同じ言葉”で色を扱えるようにしたい
    色の意図や差分を共有しやすくするため、値の直書きではなくトークン名を共通言語として揃える。(レビューや確認のコストも下がる)
  • 仕様確認やレビュー、AI への活用でも確実な前提を作りたい
    トークンを単一の参照元(Single Source of Truth)として揃えることで、デザイン確認・実装判断・ツール活用時の前提が一致しやすくなる。

トークン運用では、まずToken Integrity(定義されたトークンだけが使われる状態)を崩さないことが重要になります。

方針:Base TokenはCSS Custom Propertiesを採用

Radix ColorsはCSS Custom Propertiesを前提としたカラーシステムです。
:root / [data-theme] / コンテナ / メディアクエリといったスコープ単位で値を再定義できるため、次のような運用が自然に成立します。

  • light/darkテーマ切替
  • コンポーネント/コンテナ単位での上書き
  • DevToolsで「今どのトークンが効いているか」を追跡

一方、SCSS変数はコンパイル時に値が確定するため、ランタイムでのスコープ上書きやテーマ切替とは構造的に相性が良くありません。
そのため Base Token レイヤーは CSS 変数(--color-*)に統一しました。

(参考:Radix Colors公式)
https://www.radix-ui.com/colors

レイヤリング:Base Tokenと Semantic Tokenを分ける

  • Base Token:値(スケール)を表すトークン(例:--color-neutral-1..12
  • Semantic Token:役割を表すトークン(例:--button-primary-bg

ここでいう「デザイントークン」は、CSS Custom Properties(--*)として定義・参照するトークンを指しています。
(参考:MDN - Using CSS custom properties)
https://developer.mozilla.org/docs/Web/CSS/Using_CSS_custom_properties

ただし、初期段階でSemantic Tokenを完璧に整えるのは現実的に難しいことが多いです。
そこで過渡期のルールとして、次のように整理しました。

  • 新規/修正コードでは、可能な限りvar(--color-*)を直接使う
  • 意味が必要な場合のみ、SCSS変数をsemantic wrapper(意味のラッパー)として限定利用する
$section-title-color: var(--color-neutral-12)
.title 
  color: $section-title-color

今後は、デザインシステム(共通コンポーネント/スタイルガイド/トークン運用ルール)の整備に合わせて、Base Token(値)と Semantic Token(役割)を段階的に分離していく予定です。
最終的には、各コンポーネントは--button-primary-bgなどのSemantic Token を参照し、Base Token(--color-*)への直接依存を減らす構造へ寄せていきます。

運用面の課題:静かに忍び寄る「未定義トークン」

一方、CSS Custom Propertiesはランタイム評価なので、例えば次のようなタイポはビルドで落ちないことがあります。

.example
  color: var(--colro-neutral-12) // typo(--color ではなく --colro)

この状態が続くと、存在しないトークンが自然発生してコードベースに蓄積し、せっかくの体系が崩れてしまいます。
つまりトークン運用では 「定義されたトークンだけを使わせる静的検証」 が必須になります。

対策:Stylelint を静的検証として組み込む

ここで「TSで型定義すれば十分では?」と思う方もいるかもしれません。実際、私も最初はそう考えました。 もちろん、デザイントークンをTypeScriptやJSONで一元管理し、そこからCSSや型定義ファイルを自動生成する運用も広く採用されています。

ただし、SCSSコンパイラにとってvar(--token)は単なる文字列であり、その変数が本当に定義されているかは関知しません。 つまり、TypeScript側でトークン定義を完璧に型安全にしても、SCSSファイル上での記述ミス(Typo)はビルドエラーにならず、ブラウザで表示崩れが起きるまで気づけないのです。 そのため、スタイルファイル側の記述を静的に検証する仕組みが別途必要になります。

そこで、スタイル層に一番近いところで効く Stylelintを採用し、カスタムルールで次を保証しました。
(参考:Stylelint公式)
https://stylelint.io/

  • グローバルトークン:トークン定義ファイルの:rootに宣言された--*
  • ローカルトークン:同一ファイル内のセレクタブロックで宣言された--*
  • 検査対象:宣言値に含まれるすべてのvar(--*)の参照
  • 判定
    • ローカルにあればOK
    • グローバルにあればOK
    • どちらにも無ければエラー/警告

実装のポイントは、単純な文字列検索ではなく、SCSSをPostCSSベースでASTとして解析し、値の中のvar()引数(=トークン名)を安全に抽出して照合します。
単純な文字列検索ではgradient/calc/複合値で漏れが出やすいため、ASTベースに寄せるのが安定でした。

(参考:PostCSS)
- https://postcss.org/

以下は、この検証ルールの核心となるロジックの抜粋です。 PostCSS ASTを使ってCSS宣言(decl)を巡回し、値に含まれるvar()関数の中身が、事前に許可されたトークンリスト(DEFINED_TOKENS)に存在するかをチェックしています。

/* stylelint-plugin核心ロジックのイメージ */
const valueParser = require("postcss-value-parser");

// CSS全体の宣言を走査
root.walkDecls((decl) => {
  const value = decl.value;
  // var() が含まれていなければスキップ
  if (!value.includes("var(")) return;

  // 値をAST解析してvar(--token)を抽出
  valueParser(value).walk((node) => {
    // 関数ノードかつ名前が var の場合
    if (node.type === "function" && node.value === "var") {
      const arg = node.nodes[0]; // 第1引数を取得
      const tokenName = arg?.value;

      // --から始まるカスタムプロパティ、かつ未定義であればエラー報告
      if (tokenName?.startsWith("--") && !DEFINED_TOKENS.has(tokenName)) {
        stylelint.utils.report({
          result,
          ruleName: "custom/no-unknown-token",
          message: `Unknown token: "${tokenName}"`,
          node: decl,
          word: tokenName,
        });
      }
    }
  });
});

運用としてはIDEのStylelint プラグイン/ pre-commitに加えてCIでも検証し、最終的にマージ時点でToken Integrityを担保しています。

まとめ

まだまだ理想のデザインシステムへの道は始まったばかりですが、まずは『色』という土台を固めることができました。

  • 色定義が散在していた状態を整理し、Radix Colorsをベースに カラートークンをCSS Custom Properties(--color-*)として集約しました。
  • トークン運用ではToken Integrity(定義されたトークンだけが使われる状態) を崩さないことが重要なため、未定義・タイポ混入を早期に検知できるよう Stylelint のカスタムルール+CI で静的検証を組み込みました。
  • 現時点ではvar(--color-*)を直接参照する箇所も残しつつ、今後のデザインシステム整備に合わせてSemantic Token(例:--button-primary-bg)を拡充し、段階的に移行していく予定。