ブランドトークンを別プロダクトに移植する -- tokens.css 1ファイルで Hugo と React SPA のダーク/ライト両対応を済ませた話
個人開発で複数プロダクトを横断するブランドの作り方
はじめに
個人開発で複数プロダクトを持っていると、毎回同じ問題にぶつかります。
「このプロダクトもブログと同じトーンに揃えたい。でもどうやって?」
私は今、ちらりんブログ(Hugo製)と Simple ZTA Access(React SPA、社内向けのリモートアクセス基盤)という2つのプロダクトを並行して育てています。フレームワークも違えば、デプロイ方式も違う。共通点といえばブランドカラーとフォントくらいです。
結論を先に書くと、tokens.css という CSS 変数だけを記述したファイルを 1 枚、ブログから React SPA にそのままコピーする だけで、ブランドの統一とダーク/ライト両対応を同時に解決できました。
CSS-in-JS のテーマプロバイダも、Tailwind のカスタム設定ファイルも、Storybook も使っていません。CSS 変数だけです。この記事では、その移植の段取りと実装の落とし穴を、Phase 1 から 4 までの 4 段階に分けて整理します。
出発点 – 2 つのプロダクトと不揃いなインライン色
ちらりんブログは 2026 年 3 月のリブランド(renewal/v2)で、トークン体系を static/css/tokens.css に集約済みでした。アクセントはミント(OKLCH 0.82 0.14 175)、ダークが既定で、<html> に .light クラスを付けるとライトモードに切り替わる、という単純な仕掛けです。
一方、Simple ZTA Access(以下 SZTA)は React + Vite の SPA で、初期実装時にインラインスタイルへ色リテラルを直接書いていました。
// Before: 各ページに散在
<div style={{
background: '#1a1a2e',
color: '#e0e0e0',
border: '1px solid #2a2a3e',
}}>この状態でダークモード/ライトモード切替の要件が出てきました。試しに <html> の背景色だけライトに変えてみると、案の定、全ページで本文カードが暗いまま浮くという事故が起きます。インライン色は CSS の表現力(モード切替セレクタ)の外側にあるからです。
愚直に三項演算子で mode === 'light' ? '#fff' : '#1a1a2e' と書き換えていく案も検討しました。が、対象は 14 ページ。テーブル、バッジ、SVG 図、モーダル、フォームと種類も多く、素直にやれば 1 週間は溶ける作業量です。これは設計の問題なので、設計で解くことにしました。
tokens.css を SSOT にするという割り切り
採った方針はとても素朴です。
- ちらりんブログの
static/css/tokens.cssをそのままsimple-zta-access/web/src/styles/tokens.cssにコピーする - React SPA の
main.tsxで最初に import する - インラインスタイルの色リテラルは すべて
var(--...)に置換する ルールをCLAUDE.mdに明文化する
CSS-in-JS のテーマプロバイダや、JS オブジェクトに色を持たせるアプローチも検討しましたが、見送りました。理由は 3 つあります。
- フレームワーク非依存になる – CSS 変数はブラウザ標準なので、Hugo が吐くプレーン HTML でも、React SPA でも、Vue でも同じファイルが効く。将来 Astro に移っても変わらない
- ランタイムコストがゼロ – JS でテーマを切り替えると初期描画前に再レンダリングが走るが、CSS 変数なら
<html class>を変えるだけで全要素が同期する - 差分が grep で追える – 「
#1a1a2eを消したい」と思ったらgrep -r '#[0-9a-fA-F]\{3,6\}'で漏れなく検出できる。テーマプロバイダ経由だと変数名が散らばって追いにくい
トークンは大きく 5 系統に分けています。
| 系統 | 例 | 用途 |
|---|---|---|
| アクセント | --accent / --accent-soft / --accent-on | ブランド主色とその派生 |
| 背景 | --bg / --bg-elev / --bg-card | レイヤー別の背景色 |
| テキスト | --text / --text-dim / --muted | コントラスト 3 段階 |
| 罫線 | --border / --border-str | 通常と強調の 2 段階 |
| 状態 | --ok / --warn / --danger + 各 -soft | バッジ/通知用 |
ダークモードを :root に、ライトモードを :root.light に書く。これだけで両モードの値が一元管理されます。
:root {
--accent: oklch(0.82 0.14 175);
--bg: #0b1116;
--bg-card: #131c25;
--text: #e8ecf1;
--border: #1d2834;
}
:root.light {
--bg: #f4f8fa;
--bg-card: #ffffff;
--text: #0f1a22;
--border: #e2ebf0;
}OKLCH を使ったのはアクセント系だけで、背景やテキストは伝統的な hex を使っています。OKLCH は彩度や明度の調整がしやすい一方、IDE のカラープレビューがまだ追いついていないので、感覚で値を当てる用途に絞っています。
FOUC (Flash of Unstyled Content) 対策として、index.html の <head> に同期スクリプトを 1 つ仕込みました。
<script>
(function() {
var mode = localStorage.getItem('chirarin.mode');
if (mode === 'light') document.documentElement.classList.add('light');
})();
</script>JS が実行される前にクラスが付くので、ライトモードユーザーが一瞬暗い画面を見る、という事故が防げます。あとは React 側の ModeToggle コンポーネントが localStorage.chirarin.mode を更新するだけです。chirarin. というプレフィクスを付けてあるのは、将来複数のキャラクター系統(claurin. など)が増えても衝突しないようにするためです。
Phase 分割 – 一気にやらない
トークンは決まったので、次は移植の進め方です。14 ページを一気に書き換えるのは現実的ではないので、4 つの Phase に分けました。
| Phase | スコープ | 狙い |
|---|---|---|
| Phase 1 | 基盤(tokens.css / base.css / Header / ModeToggle / Login) | 「ベース」が両モードで動く状態を最初に作る |
| Phase 2 | Dashboard + Agents 一覧 | 一番見られる画面だけ先に整える |
| Phase 3 | Guide(TOC / テーブル / SVG 図) | 表現要素が多い画面で「型」を確立する |
| Phase 4 | 残り全 14 ページ + GuideDiagrams SVG | Phase 3 で確立した型を機械的に展開 |
Phase 1 で基盤が動くようにしておくと、Phase 2 以降は「色を var(--...) に置き換える単純作業」に集約できます。逆に基盤が不完全なまま個別ページに手を入れると、ページごとに微妙に挙動が違って、何を直しているのか分からなくなります。
Phase 3 を「Guide(ドキュメント画面)」に充てたのも意図があります。Guide は本文・見出し・目次・テーブル・SVG 図と、表現の種類が一番多い画面です。ここで通用するパターンができれば、他のページは部品の組み合わせで済みます。最も難しい場所を中盤に置く、という配分です。
落とし穴 1 – インライン色を機械的に検出する
Phase 4 のような「残り全 14 ページ」で頼りになるのは grep です。
grep -rn --include='*.tsx' "'#[0-9a-fA-F]\{3,6\}'" web/src/このコマンドで 200 箇所以上のインライン色リテラルが出てきました。1 行ずつ確認して var(--...) に置換していくのですが、ここで注意点が 1 つあります。
色相だけで対応トークンを決めると失敗します。 例えば #fff を見つけて var(--bg-card) に置き換えるのは正しいケースもありますが、「丸で囲まれた中の文字色」のように --accent-on(アクセント上のテキスト)が正解の場合もあります。「この色はどんな意味で使われていたか」を 1 つずつ判断する必要があります。
私の場合、以下のルールで割り当てました。
- カード/モーダルの背景 →
--bg-card - ヘッダー/フッター/サイドバーなど沈める背景 →
--bg-elev - ページ全体の地 →
--bg - 罫線 →
--border(区切り) /--border-str(フォーカス) - 本文 →
--text - 補助テキスト →
--text-dim(ラベル) /--muted(キャプション)
意味で分類するので、grep 結果に対して機械的な sed 置換は使えません。地味ですが、ここを丁寧にやらないと後で「色は変えたのに崩れる」案件が出てきます。
落とし穴 2 – テーブル zebra の三項演算子
SZTA のテーブルには、行の縞模様(zebra striping)で mode === 'light' ? '#fff' : '#f9f9f9' のような三項演算子が散在していました。
// Before
<tr style={{
background: index % 2 === 0
? (mode === 'light' ? '#ffffff' : '#1e2530')
: (mode === 'light' ? '#f9f9f9' : '#1a2028'),
}}>これを var(--bg-card) と var(--bg-elev) に置き換えます。
// After
<tr style={{
background: index % 2 === 0 ? 'var(--bg-card)' : 'var(--bg-elev)',
}}>ダーク時は --bg-card (#131c25) と --bg-elev (#111921) で 2% 程度の差、ライト時は #ffffff と #f4f8fa で 4% 程度の差が出ます。意図して値を寄せているので、目には自然な縞に見えます。mode プロップを参照する必要がなくなり、useTheme() フックの呼び出しもまるごと削除できました。
CSS 変数の良いところは、値の階層を維持したまま、両モードで滑らかに切り替わることです。「カードはレイヤー 2、ヘッダーはレイヤー 1」という意味の階層が、ダークでもライトでも同じ意味で表現されます。
落とし穴 3 – 「文字色を背景に使う」事故
Phase 4 で一番ヒヤッとしたのが、未ログイン時の上部ナビゲーションです。
リファクタ前の実装では、定数 COLOR_PRIMARY がアクセント色として定義され、ナビ背景に使われていました。
const COLOR_PRIMARY = '#1a1a2e';
<nav style={{ background: COLOR_PRIMARY, color: '#ffffff' }}>これを「アクセント色をトークン化する」という意図で、こう書き換えてしまいました。
const COLOR_PRIMARY = 'var(--text)'; // 致命的
ダークモードで --text = #e8ecf1(ほぼ白)です。ナビ背景が真っ白に反転して、白地に白文字で読めなくなります。 ライト時に --text が黒系になるので逆も成立して、レビューしていた自分も一瞬「これでよかったかな」と思いかけました。
原因は単純で、「文字色を背景に使った」だけです。教科書的にはやらないことですが、リファクタの最中だと無意識にやってしまうので注意が要ります。
修正は素直に意味の通る形に。
<nav style={{
background: 'var(--bg-card)',
borderBottom: '1px solid var(--border)',
}}>ナビは「カード扱いの背景」+「下罫線」で表現するのが自然です。box-shadow も外して、罫線 1 本に統一しました。
トークン化作業の本質は 「リテラルを変数名に置き換える」のではなく「意味で命名する」 ことです。COLOR_PRIMARY のような汎用名を残すと、その変数が何の意味なのかが分からなくなって事故が起きます。
落とし穴 4 – SVG 図も CSS 変数で塗れる
SZTA の Guide 画面には、データフローを示す SVG 図がいくつか埋め込まれています。これがダーク/ライト切替の鬼門でした。
最初は「SVG だけは画像として書き出すしかない」と思い込んでいましたが、よく見るとモダンブラウザでは SVG の fill / stroke 属性に CSS 変数が使える ことを思い出しました。
<svg width="600" height="200">
<rect
x="20" y="20" width="160" height="80"
fill="var(--bg-elev)"
stroke="var(--border-str)"
/>
<text x="100" y="65" fill="var(--text)" fontSize="14">
Portal
</text>
</svg>var() を fill/stroke に書けば、両モードで自動追従します。HTML の他の要素と同じ仕組みで、SVG だけ別世界にしなくて済みます。
さらに、ノード間を結ぶ矢印の色を「アクセントの薄い版」にしたかったので、color-mix() でその場で生成しました。
<line
stroke={`color-mix(in oklab, var(--accent) 40%, transparent)`}
strokeWidth="2"
/>color-mix() は OKLCH/OKLab 空間での混色を 1 行で書ける関数で、Chrome/Safari/Firefox の最近のバージョンで使えます。トークンを増やさずに派生色を使える、という意味でも便利です。
落とし穴 5 – ステータスバッジは soft + 1px border
ステータスバッジ(active / pending / failed / closed)も全画面で出てきます。リファクタ前は #22c55e22 のような hex + alpha リテラルが各所にハードコードされていました。
トークンを 5 つ追加して、対応関係を明確にしました。
| 状態 | 背景 | 文字色 | 罫線 |
|---|---|---|---|
| active | var(--ok-soft) | var(--ok) | 1px solid var(--ok) |
| pending | color-mix(in oklab, var(--warn) 18%, transparent) | var(--warn) | 1px solid var(--warn) |
| failed | var(--danger-soft) | var(--danger) | 1px solid var(--danger) |
| closed | (背景なし) | var(--muted) | 1px solid var(--border) |
| timed_out | color-mix(in oklab, var(--warn) 18%, transparent) | var(--warn) | 1px solid var(--warn) |
ポイントは -soft 版の背景に 1px の同色ボーダー を組み合わせることです。背景の薄い色だけだとライトモードで境界が消えてしまうのですが、同色 1px を足すと両モードでバッジの輪郭が明確に出ます。
color-mix() で --warn-soft を都度生成しているのは、トークン数を増やしたくなかったからです。--ok と --danger は使用頻度が高いので -soft を別トークンとして持ちましたが、--warn のように 2 〜 3 箇所しか使わない色は、その場合成で十分です。
残した固定色 – 録画バナーは黒緑のまま
トークン化の話をすると「全部変数にすべきだ」という主張になりがちですが、私はそれには反対です。意味が強い UI は固定色を残すべき だと思っています。
SZTA には Terminal/Telnet/RDP セッションの録画機能があり、画面上部に「REC ●」のバナーが表示されます。これは #000000 背景に #00ff00 の文字、という昔ながらの「録画中」シグナルで、わざと固定色にしています。
理由は 2 つあります。
- 意味のシグナルを薄めない – 録画中の警告を「サイトのトーンに合わせて柔らかく」してしまうと、警告の効力が落ちる。録画中であることは強く伝わってよい
- xterm.js の表示領域と地続きになる – ターミナルの背景は黒、文字は緑/白という前提で動いているので、その上に乗るバナーも同じ語彙で揃えたほうが視覚的に連続する
CLAUDE.md の UI ルールにも「xterm.js / 録画バナーなど、視認性・連続性を優先して固定色を残している箇所がある」と明文化してあります。トークン化を「教義」にせず、「目的に対して使う道具」として扱う、という線引きです。
結果 – 全 14 ページが両モードで馴染む
4 Phase 合算でコード差分は +1,286 / -905 行(主にインライン色リテラルの置き換え)。useTheme() を呼び出していたコンポーネントは大半が削除でき、状態管理も 1 段シンプルになりました。
仕上がりを見ると、当たり前ですが両モードで馴染みます。ダークでは深い藍ベースにミントのアクセント、ライトではほぼ白の地に同じミントが乗る。色相がずれていないので「同じプロダクトの別モード」に見えます。
そして何より大きいのは、この後で増える新しいページが、何もしなくてもトークンに追従する ことです。新規コンポーネントを書くとき、私(と Claude)は CLAUDE.md のルールに従って var(--bg-card) などを使います。それだけで自動的に両モードで動きます。設計の元を取れる構造になったと感じています。
運用ルールを CLAUDE.md に明文化する
トークンを導入した後の運用が一番大事です。せっかく整えても、また誰か(あるいは過去の自分や、AI コーディングアシスタント)がインラインで #1a1a2e を書き始めたら台無しになります。
私のリポジトリでは AI アシスタント(Claude Code)向けに CLAUDE.md を置いていて、UI 実装ルールをそこに書いています。
- 色は全て
var(--...)の CSS 変数を使用する。inline style で#xxxリテラル・rgba()を直接書かない- トークン定義場所:
web/src/styles/tokens.css(:root= ダーク、:root.light= ライト)- ステータスバッジは
var(--ok-soft)/var(--danger-soft)/color-mix(var(--warn) ...)背景 + 同色 1px border- 例外: xterm.js / 録画バナーなど、視認性・連続性を優先して固定色を残している箇所がある。新規コードでむやみに増やさない
人間のレビュー観点としても、AI への指示としても、同じ文書が効きます。grep '#[0-9a-fA-F]\{3,6\}' web/src/ を CI で走らせて固定色を検知する、という選択肢もありますが、例外(xterm/録画バナー)があるので私は文書ベースに留めています。
教訓
今回の移植から得た学びをまとめます。
- デザインシステムは大げさに始めなくていい。 Storybook も Tailwind カスタム設定もなしで、
tokens.css1 ファイル +var(--...)だけで実用に耐える。複数プロダクトを横断するブランドなら、まずこの粒度から - CSS 変数はフレームワーク非依存。 Hugo で動いた tokens.css がそのまま React SPA で動く。Vue でも Astro でも同じはずで、移植コストが極小になる
:rootと:root.lightの 2 段で全モードを表現できる。 クラス名でモード切替する仕組みなら、JS フレームワークに依存しない- Phase 分割で「基盤 → 主要画面 → 表現要素の多い画面 → 残り全部」の順に進める。 最も難しい画面を中盤に置くと、その後の機械的展開が楽になる
- トークン化は「リテラルを変数に置き換える」のではなく「意味で命名する」作業。
COLOR_PRIMARYのような汎用名を残すと「文字色を背景に使う」事故が起きる - SVG 図も
fill="var(--...)"で両モード対応できる。 画像書き出しに逃げる前に試す価値がある - 意味が強い UI(録画バナーなど)は固定色を残す判断も重要。 トークン化を教義にしない
CLAUDE.mdに運用ルールを書く。 人間レビューと AI 支援の両方に効く
個人開発で複数プロダクトを抱えている方の参考になれば幸いです。CSS 変数だけでここまでできる、というのは私自身、やってみて初めて納得した感覚でした。
自宅サーバ運用の全体像は 自宅サーバ運用の完全ガイド — Proxmox + Cloudflare Tunnel + Docker で個人ブログを公開し続ける にまとめています。Proxmox クラスタ・公開経路・Docker 本番化・障害対応までを 1 ページで通読できる Pillar ガイドです。
コメント