自宅プロダクト群を1つの UI Kit でブランド統一した話 — CSS変数だけでHugo / Jinja2 / Next.js / Tailwind CDN / shadcn/uiを束ねる
自宅で増えていく個人プロダクトの見た目をブランド統一するため、tokens.css 1ファイルとIIFEだけの小さなUI Kitを作りました。Hugo・Jinja2・Tailwind CDN・Next.js+shadcn/uiまで、フレームワークごとの罠と実装を記録します。
はじめに
自宅で個人プロダクトが5つを超えたあたりから、見た目がバラバラ問題が始まりました。
ブログは Hugo、ダッシュボードは FastAPI + Jinja2、ミニゲームは素のHTML、研修システムは Next.js + shadcn/ui、ZTAクライアントは React + Vite。フロントの作り方が全部違うのに「ブランドの色は揃えたい」という、そこそこ厄介な要件です。
最初は CSS-in-JS のテーマプロバイダや Tailwind のプリセット拡張を考えました。が、結局採用したのは tokens.css 1 ファイルと小さな IIFE だけ という、かなり地味な構成です。
この記事では、5つのフレームワーク全てで使える最小契約をどう設計したか、各プロダクトに組み込むときに踏んだ罠(特に shadcn/ui の規約衝突と Next.js middleware)、そして「なぜテーマプロバイダではなく CSS 変数だけで済ませたか」を書きます。
何を解決したかったか
問題はシンプルで、しかし放置すると技術的負債としてじわじわ効いてきます。
- ブログのアクセントカラーを変えたら、ダッシュボードのアクセントカラーがズレる
- ライトモード対応をブログに入れたら、他のプロダクトはダークのまま取り残される
- 新しくプロダクトを作るたびに「色変数どうしよう」から始まる
- 各プロダクトが独自に
bg-gray-950#0b1116oklch(...)を散らばらせているので、後で統一しようにも置換が地獄
つまり「SSOT (Single Source of Truth) を持ちたい」という、設計の人なら誰もが知っている話です。問題は SSOT を どの粒度で、どんな形式で 配るか。
候補は3つありました。
| 案 | 採用しなかった理由 |
|---|---|
| CSS-in-JS(Emotion / styled-components)+ React Context のテーマプロバイダ | Hugo / Jinja2 / 素のHTML には React がそもそも無い。プロダクト全体を React 化するつもりは無い |
Tailwind プリセット(tailwind.config.ts 共有) | Hugo はビルド工程に Node を入れたくない。CDN 利用の Jinja2 は tailwind.config.ts が読めない |
| CSS 変数(カスタムプロパティ)だけ | フレームワークを問わずブラウザが直接解釈する。ビルド工程不要 |
3番目の「CSS 変数だけ」が、関係するプロダクトの最小公倍数でした。React にも Hugo にも素の HTML にも、CSS は必ず存在します。
UI Kit の最小契約
~/projects/chillarin-ui-kit/dist/ に置いたのは以下の 5 ファイルだけです。
dist/
tokens.css # 配色・タイポの CSS 変数(dark/light)
base.css # tokens を使った最小ベース(h1〜h6, code, button, table)
mode-toggle.js # <html>.light を切替 + localStorage 永続化
_head.html # <head> にコピペする雛形(FOUC 防止 IIFE 込み)
_toggle.html # トグルボタンの HTML(SVG 込み)
images/ # チンチラのロゴとヒーロー画像tokens.css の中身は単純です。アクセント(ミント)と、ダーク・ライト 2 セットの色を CSS 変数で定義しているだけ。
:root {
--accent: oklch(0.82 0.14 175);
--accent-glow: oklch(0.82 0.14 175 / .5);
--bg: #0b1116;
--bg-elev: #111921;
--bg-card: #131c25;
--text: #e8ecf1;
--text-dim: #b8c3cf;
--muted: #8a96a6;
--border: #1d2834;
--border-str: #2b3a4c;
font-feature-settings: "palt" 1;
}
:root.light {
--bg: #f4f8fa;
--bg-elev: #ffffff;
--bg-card: #ffffff;
--text: #0f1a22;
--text-dim: #2a3642;
--muted: #64707c;
--border: #e2ebf0;
--border-str: #cbd8e0;
}このキットを使うプロダクトに守ってもらうルールは、たった 5 つです。
| ルール | 値 | 理由 |
|---|---|---|
| テーマクラス | <html> に light クラスでライト、無しでダーク | mode-toggle.js の前提 |
| localStorage キー | chirarin.mode(値は light か dark) | プロダクト間でテーマ設定を共有可能 |
| トグル selector | .mode-toggle | mode-toggle.js がこの単一の querySelector で拾う |
| 色は変数経由 | var(--bg) var(--text) var(--accent) | テーマ切替が効くため |
| ハードコード禁止 | background: #0b1116 のような直書きはダメ | ライトモードで破綻 |
これだけです。フレームワークの指定はゼロ。<html> にクラスを付けるルールと、CSS 変数を参照するルールの 2 つに集約しています。
なぜ「ダークがデフォルト」なのか
:root をダーク、:root.light をライトにしたのは、ブログの読者層がダークで読む人が大半だからです。FOUC(Flash of Unstyled Content)の防止 IIFE も、デフォルトをダークにしたほうがコードが短くなります。
<script>
(function(){
try {
var m = localStorage.getItem('chirarin.mode');
if (m === 'light') document.documentElement.classList.add('light');
} catch (e) {}
})();
</script>light を選んだ場合だけクラスを付ける。<link> タグより前に置いて、CSS が当たる瞬間にはもうクラスが確定している状態を作ります。
プロダクト別の組み込み
5 つのプロダクトに同じキットを組み込みました。難易度は完全にバラバラで、フレームワークの特性と規約に応じて踏む罠が変わります。
Hugo (ちらりんブログ) — そのまま vendor、追加作業ほぼなし
ちらりんブログ自身が tokens.css の Source of Truth なので、UI Kit はブログから dist を生成する立場です(sync-from-blog.sh で逆方向に同期)。Hugo 側でやることは無し。新しいプロダクトをブログ系統に作るときも、static/ に cp するだけで終わります。
Hugo はテンプレートに <head> のスニペットを入れる場所が layouts/_default/baseof.html 1 箇所しかないので、最も導入が楽なターゲットです。
Jinja2 + Tailwind CDN (system-dashboard) — Tailwind の色名にも CSS 変数を流し込む
ここから難易度が上がります。system-dashboard は FastAPI + Jinja2 + Tailwind CDN という構成で、Tailwind がランタイム CDN なので tailwind.config.ts が使えません。テンプレートの中に bg-gray-950 text-gray-100 といった Tailwind 標準の色が大量に書かれていて、これを UI Kit のダーク・ライト両対応に従わせる必要がありました。
最初は「全部 bg-[var(--bg)] の任意値構文に置換するか…」と考えましたが、もっと楽な方法がありました。Tailwind CDN を読み込んだ直後に、インライン script で tailwind.config を上書きする というやり方です。
<!-- Tailwind CDN + UI Kit エイリアス登録 -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: ['selector', ':root:not(.light)'],
theme: {
extend: {
colors: {
bg: 'var(--bg)',
'bg-elev': 'var(--bg-elev)',
'bg-card': 'var(--bg-card)',
text: 'var(--text)',
'text-dim': 'var(--text-dim)',
muted: 'var(--muted)',
border: 'var(--border)',
'border-str': 'var(--border-str)',
accent: 'var(--accent)',
'accent-on': 'var(--accent-on)',
'accent-soft':'var(--accent-soft)',
'accent-line':'var(--accent-line)',
}
}
}
}
</script>これで bg-bg text-text border-border-str のような UI Kit ネイティブのクラス名が Tailwind の中で使えるようになります。あとは bg-gray-950 → bg-bg、text-gray-100 → text-text を sed で一括置換するだけ。テンプレートは Tailwind 流のまま、見た目だけが UI Kit に揃います。
ポイントは darkMode: ['selector', ':root:not(.light)']。これで dark: バリアントが「<html> に light クラスが無いとき」に効くようになり、UI Kit 規約とちょうど一致します。
教訓は、Tailwind CDN は実は config を JS で書き換えられるということ。CDN 利用イコール設定不能、と思い込んでいると損をします。
素のHTML (blog-minigames) — _head.html をコピペするだけ
ミニゲーム集のランディングは Hugo すら通っていない素のHTML 1 枚です。ここでは tokens.css を vendor すらせず、<style> タグに インラインで埋め込みました。fetch で外部 CSS を取りに行く 1 往復が惜しいレベルの軽量ページなので、self-contained にしてしまったほうが速い。
<style>
/* === chillarin-ui-kit tokens (inline 同梱・self-contained) === */
:root {
--accent: oklch(0.82 0.14 175);
--bg: #0b1116;
--bg-elev: #111921;
--text: #e8ecf1;
/* ... */
}
:root.light {
--bg: #f4f8fa;
--text: #0f1a22;
/* ... */
}
</style>「契約だけ守ればキットの形態は問わない」のが、この設計の良いところです。tokens.css をファイルとして参照しても、<style> でインライン化しても、Tailwind の color にエイリアスしても、全部正しい使い方です。
Next.js + shadcn/ui (TrainingCheck) — 規約衝突と middleware の罠
ここが今回いちばん時間を吸われた箇所です。
shadcn/ui は内部で :root がライト・.dark クラスでダーク という規約を持っています。これは Tailwind の dark: バリアントが darkMode: ["class"] 設定で .dark を見るため、shadcn の全コンポーネントがそれを前提に書かれているからです。
一方、UI Kit の規約は :root がダーク・.light クラスでライト。完全に逆向きです。
最初は「UI Kit に合わせて shadcn を書き換えればいいか」と考えましたが、これは破滅的な選択でした。Button Dialog Toast をはじめ、shadcn の全コンポーネントが破綻します。darkMode: ["class"] を darkMode: [':root:not(.light)'] に変えたところで、コンポーネント側のクラス参照は変えられません。
アダプタ方式で 3 層を分離する
解決策は、3 層に分けてそれぞれ違う規約を成立させる アダプタ方式 でした。
| 層 | 規約 | 何をする |
|---|---|---|
ブラウザに見える <html> | .dark でダーク(shadcn 規約) | shadcn コンポーネントが期待する DOM 状態 |
| localStorage | chirarin.mode で light / dark(UI Kit 規約) | プロダクト間で共有する単一の真実 |
globals.css の HSL 値 | UI Kit ブランドカラー | 見た目だけブランドに揃える |
具体的には src/app/globals.css で、shadcn のテンプレートをいじらずに HSL 値だけ を UI Kit カラーに置き換えました。
@layer base {
:root {
/* ライトモード (UI Kit light) */
--background: 200 37% 97%;
--foreground: 205 39% 10%;
--primary: 174 60% 50%;
--border: 201 32% 91%;
/* ... */
}
.dark {
/* ダークモード (UI Kit dark - chillarin-blog と同色) */
--background: 207 33% 7%;
--foreground: 213 24% 93%;
--primary: 174 60% 65%;
--border: 211 28% 16%;
/* ... */
}
}:root がライト・.dark がダークというファイル構造はそのまま。中身の HSL 数値だけブログのトークンに合わせます。これで shadcn は何も知らないまま UI Kit 配色になります。
次に、FOUC 防止 IIFE を 逆向き に書きました。UI Kit の localStorage キー chirarin.mode を読みつつ、shadcn のために .dark クラスを操作します。
// app/layout.tsx
const themeInitScript = `
(function(){
try {
var m = localStorage.getItem('chirarin.mode');
if (m !== 'light') document.documentElement.classList.add('dark');
} catch (e) { document.documentElement.classList.add('dark'); }
})();
`;「light 以外なら .dark を付ける」という条件にしています。null(初回訪問)でもダークになり、デフォルトがダークという UI Kit 規約と一致します。
最後に ModeToggle コンポーネント。これも .dark クラスを操作しつつ、保存先は chirarin.mode です。
const STORAGE_KEY = "chirarin.mode";
const toggle = () => {
const nowLight = document.documentElement.classList.contains("dark");
if (nowLight) {
document.documentElement.classList.remove("dark");
try { localStorage.setItem(STORAGE_KEY, "light"); } catch {}
} else {
document.documentElement.classList.add("dark");
try { localStorage.setItem(STORAGE_KEY, "dark"); } catch {}
}
setIsLight(nowLight);
};DOM 上は shadcn 規約、ストレージは UI Kit 規約、見た目はブランドカラー。3 層を アダプタで分離 することで、両方の規約を生かしたまま全プロダクトで設定が共有できます。tailwind.config.ts は触りません。これが大事で、shadcn 側の更新を将来追従するときに摩擦が生まれない構成になります。
教訓: 既存ライブラリの規約と自分の規約が衝突したら、書き換えではなくアダプタで分離する。 当たり前の話ですが、UI Kit を作っている最中は「自分の規約に揃えたい」誘惑が強いので、意識的に踏みとどまる必要があります。
Next.js middleware が public/ui-kit/ を吸い込む罠
組み込みが終わって、ログイン画面に チンチラのヒーロー画像を表示しよう とした瞬間に、新しい問題に遭遇しました。
<img src="/ui-kit/hero-chinchilla.png"> を未認証ページに置いたのに、画像が表示されない。Network タブを見ると HTTP 307 でログインページにリダイレクトされていました。
原因は src/middleware.ts の matcher です。Next.js のミドルウェアは、config.matcher で除外したパス以外を全部捕まえます。デフォルトで除外されているのは _next/static _next/image favicon.ico あたりだけ。public/ 配下に置いた 自前のリソースは除外対象に含まれていません。
つまり /ui-kit/chinchilla-logo.png のリクエストもミドルウェアに食われ、未認証なら問答無用で /login にリダイレクトされていたのです。
修正は matcher への追加 1 行です。
export const config = {
matcher: [
// Next.js内部リソースと静的ファイルを除外
// ui-kit/* : chillarin-ui-kit のブランド画像(ログイン画面でも使うため未認証で配信)
"/((?!_next/static|_next/image|favicon.ico|public/|ui-kit/).*)",
],
};ui-kit/ を除外パターンに加えるだけ。コメントには 「なぜここに ui-kit/ が必要か」 を残しています。半年後の自分か他の AI が見ても理解できるように。
教訓: ログイン画面など未認証ページでブランド画像を出すなら、middleware の matcher を必ず確認する。 ファイルを public/ に置いたから配信される、という直感は Next.js の middleware では成立しません。
React + Vite (simple-zta-access) — tokens.css をプロジェクト直下に置く
ZTA クライアント (Simple ZTA Access) は React + Vite + TypeScript の SPA です。ここでは UI Kit を static/ に vendor する代わりに、src/styles/tokens.css としてプロジェクトに同梱し、main.tsx の最初で import する方式を取りました。
// main.tsx
import './styles/tokens.css';
import './styles/base.css';理由は Vite のビルド最適化です。@import 経由で取り込めば Vite が CSS をハッシュ付きで吐き出してくれるので、キャッシュ周りが綺麗になります。SPA は静的配信先がシンプルなので、わざわざ別系統で配信する旨味も薄い。
ZTA 側のトークン移植の詳細は別記事に書いています(ちらりんブログ renewal/v2 のトークンを Simple ZTA に移植した話)。
教訓: 「キットのファイルをそのまま参照する」と「キットのファイルをプロジェクトに同梱する」のどちらが正しいかはフレームワーク次第。 契約 (CSS 変数の名前と <html> クラスの規約) さえ守れば、配布形態はプロダクトごとに変えてよい、という設計に意識的にしています。
なぜテーマプロバイダや Tailwind プリセットでなく CSS 変数だけにしたか
最後に、最初に書いた「3 案」のうち CSS 変数を選んだ理由を整理します。
1. 依存ゼロで配れる
tokens.css は単なるテキストファイルです。npm install も pip install も、ビルドツールも要りません。ファイルを cp するだけで動きます。Hugo のような Node を持たないプロダクトでも、JIT ビルドのない素の HTML でも、同じファイルが同じ意味で動きます。
2. ランタイムでテーマ切替できる
CSS 変数はブラウザがランタイムに解決するので、<html> のクラスを付け外しするだけで全カラーが瞬時に切り替わります。React の Context を介する必要が無いので、Server Component / Client Component の境界に縛られません。Next.js の Server Component の中でも、<html lang> レベルで適用されるので何も気にせず使えます。
3. フレームワーク間で共通の語彙ができる
「var(--bg) を背景に使う」というルールが、Hugo にも Jinja2 にも shadcn にも Vite SPA にも同じ意味で通じます。Tailwind プリセットで bg-brand を作ると、Tailwind を使っていないプロダクトでは無意味です。CSS 変数なら、ピュア CSS でも Tailwind の任意値構文 (bg-[var(--bg)]) でも、shadcn の HSL 値の中間表現としても使えます。最も低い共通分母を採ることで、5 つのフレームワークが並列に語れるようになりました。
4. 「契約」の薄さが運用コストを下げる
このキットの契約は実質 5 行です。
<html>にlightクラスがあればライト、無ければダーク- localStorage キーは
chirarin.mode - トグルボタンの class は
.mode-toggle - 色は
var(--xxx)で参照する - ハードコードしない
これだけ。新しいプロダクトを追加するときに「キットの仕様を勉強する」コストがほぼゼロです。半年後の自分が「あれどうやって組み込むんだっけ」と詰まる瞬間が無い。
5. SSOT を 1 箇所に集約できる
ちらりんブログの static/css/tokens.css を編集すると、sync-from-blog.sh 経由で UI Kit の dist/tokens.css が更新され、それを各プロダクトに cp し直すフローです。アクセントカラーを変えたいときに編集する場所が 1 ファイル で済みます。
逆に Tailwind プリセット方式だと、各プロダクトの tailwind.config.ts を全部書き換える必要が出てきます。テーマプロバイダ方式だと、React 化されていないプロダクトはそもそも対象外になります。CSS 変数方式は、現実的な「全プロダクトを 1 回で更新する手段」を提供してくれます。
ふりかえり
5 つのプロダクトに UI Kit を組み込んでみて、最も大きかった学びは以下です。
- 規約衝突は書き換えではなくアダプタで解く: shadcn/ui のように規約が固まっている既存ライブラリと、自分のキットの規約が逆向きでも、3 層に分離すれば共存できる。書き換えに走ると将来の追従が辛くなる
- 「最も薄い契約」を選ぶ: CSS 変数だけ、
<html>クラスだけ、.mode-toggleという単一 selector だけ。契約が薄いほど、新規プロダクトの組み込みが速くなり、フレームワークの選択肢も狭まらない - CDN ライブラリは諦める前に config の上書き手段を探す: Tailwind CDN でも
tailwind.config = {...}で十分カスタマイズできた。ビルド工程が無いから設定不能、と決めつけない - 未認証ページで使うリソースは middleware から逃がす: Next.js の matcher は除外を明示しないと吸い込む。ブランド画像は最初の体験を作る要素なので、ログイン画面でも確実に表示されるようにしておく
- 配布形態はプロダクト次第で変えていい: ファイル参照、インライン埋め込み、
import取り込み、Tailwind エイリアス。契約さえ守れば、形態はプロダクトに最適化してよい
「UI Kit を作る」と聞くとつい大袈裟な構えになりがちですが、自宅プロダクトの統一という用途なら tokens.css 1 ファイルと小さな IIFE で実用上は十分でした。フレームワークが増えるほど、薄い契約のありがたみが効いてきます。
自宅サーバ運用の全体像は 自宅サーバ運用の完全ガイド — Proxmox + Cloudflare Tunnel + Docker で個人ブログを公開し続ける にまとめています。Proxmox クラスタ・公開経路・Docker 本番化・障害対応までを 1 ページで通読できる Pillar ガイドです。
コメント