ちらりんブログ リニューアル v2 の裏側 — OKLCH トークンとダーク/ライト両対応を Hugo で実装した話
tokens.css 一枚と JS 二本で、SaaS テーマに頼らず両モード対応のブログを組み直した話
はじめに
ちらりんブログを 2026 年 4 月、デザイン全面リニューアル(v2)しました。
きっかけは「ダークモードのまま固定で書き続けてきたが、ライトモード派の読者にも見せたい」という単純な要件でした。ただ、既存の style.css にはトークンが散らばっていて、両モード対応を追加するのが現実的ではない状態でした。色が直書きされ、グレースケールも #888 のような直値が無秩序に存在し、oklch() どころか hsl() ですら統一できていませんでした。
結果として、tokens.css を新設して OKLCH ベースの設計トークンに作り替え、Variant B ヒーロー・読書進捗バー・サイドバー TOC・自動ドロップキャップまで一気に作り直すことになりました。テーマや SaaS には頼らず、Hugo の partials と shortcode、自前 CSS、JS 2 本だけで完結させています。
この記事では、リニューアルで効いた判断と効かなかった判断を、ファイルと差分の単位で振り返ります。同じく Hugo / Eleventy / Astro などで個人ブログを運用している方の参考になればと思います。
なぜ全面リニューアルしたのか
ブログ立ち上げ初期から半年ほど、私は色とトークンを場当たりで足してきました。記事カードが必要になれば style.css の末尾に .post-card { background: #131c25; } と書き足し、強調色が欲しければ #3ec3a5 を直書きする、といった具合です。
このやり方は、最初は速いです。ただ、3 ヶ月もすると次の問題が現れました。
- 同じ意味の色が複数存在する。アクセントカラーを
#3ec3a5と#42c8aaの 2 種類で使い回している箇所が見つかる - ダークモード前提なのにグレーがバラバラ。
#1d2834と#1c2630が混在し、ボーダーの濃淡が場所によって違う - ライトモード対応が事実上不可能。色を
#0b1116(背景)に直書きしているため、ライト切り替え時に上書きする CSS が肥大化する
この状態でライトモードを後付けするのは、私の経験上「モグラ叩き」になります。一箇所直すと別の場所で破綻し、それを直すと最初の場所に戻る。やる前から負ける戦いです。
そこで全面的にやり直すことにしました。設計トークンを tokens.css に集約し、style.css は意味のあるクラス名だけを書く場所に戻す。これがリニューアルの一行サマリです。
tokens.css の設計 — なぜ OKLCH を選んだか
リニューアルの中核は、わずか 55 行の tokens.css です。サイト全体の色とフォントは、ここで決まります。
:root {
/* === アクセント(両モード共通) === */
--accent-h: 175;
--accent: oklch(0.82 0.14 175);
--accent-soft: oklch(0.82 0.14 175 / .22);
--accent-line: oklch(0.72 0.14 175 / .5);
--accent-glow: oklch(0.82 0.14 175 / .5);
--accent-text: var(--accent);
/* === ダークモード(デフォルト) === */
--bg: #0b1116;
--bg-elev: #111921;
--bg-card: #131c25;
--border: #1d2834;
--text: #e8ecf1;
--text-dim: #b8c3cf;
--muted: #8a96a6;
}
:root.light {
--bg: #f4f8fa;
--bg-elev: #ffffff;
--bg-card: #ffffff;
--border: #e2ebf0;
--text: #0f1a22;
--text-dim: #2a3642;
--muted: #64707c;
}ポイントは 2 つあります。
1. アクセントカラーは OKLCH 一本で表す
CSS Color Module Level 4 で標準化された oklch() は、人間の知覚に近い色空間で色を扱える関数です。HSL と違って明度(Lightness)を上げ下げしてもサチュレーションが破綻しない、というのが大きな利点です。
ちらりんブログのアクセントは「ミント」を選びました。コードは oklch(0.82 0.14 175) の 1 行だけ。これに対して、
- 透明度を変えた背景用 →
oklch(0.82 0.14 175 / .22)(--accent-soft) - 明度を 1 段下げた線用 →
oklch(0.72 0.14 175 / .5)(--accent-line) - 同じ色を発光に使う →
oklch(0.82 0.14 175 / .5)(--accent-glow)
と、明度・透明度を独立に動かしてバリエーションを作っています。HSL でこれをやると、明度を下げた瞬間に「色が泥っぽくなる」ことが多く、結局色相を微調整する羽目になります。OKLCH ではその調整が要りませんでした。
2. ダーク/ライトは :root と :root.light の二系統だけ
色のセマンティクス(--bg --text --border など)は同じ名前で、値だけが両モードで切り替わります。コンポーネント側の CSS は常に var(--bg-card) のように書くので、モード切り替えで触るべき場所がゼロになります。
これに気づいてから、style.css 内の @media (prefers-color-scheme: light) 上書きブロックは全部消えました。トークンが正しく設計されていれば、上書きは不要になります。
ダーク/ライトトグル — 33 行の JS と FOUC 防止
トグルボタンの実装は、mode-toggle.js という 33 行の素朴な IIFE です。
(function () {
'use strict';
var STORAGE_KEY = 'chirarin.mode';
function apply(mode) {
var root = document.documentElement;
if (mode === 'light') root.classList.add('light');
else root.classList.remove('light');
}
document.addEventListener('DOMContentLoaded', function () {
var btn = document.querySelector('.mode-toggle');
if (!btn) return;
btn.addEventListener('click', function () {
var nowLight = !document.documentElement.classList.contains('light');
apply(nowLight ? 'light' : 'dark');
try { localStorage.setItem(STORAGE_KEY, nowLight ? 'light' : 'dark'); } catch (e) {}
});
});
})();クリックで :root に .light クラスを付け外し、localStorage に保存するだけです。「ダークモード切替を入れたい」と聞くと React の Provider やフックを思い浮かべがちですが、ブログにはここまでのもので十分でした。
問題は FOUC(Flash of Unstyled Content) です。DOMContentLoaded を待つと、ライトモード設定の読者が一瞬ダークモードを見せられてしまいます。
これを防ぐため、baseof.html の <head> 先頭で同期的に localStorage を読みます。
<head>
<meta charset="UTF-8">
<script>
(function(){
try {
var m = localStorage.getItem('chirarin.mode');
if (m === 'light') document.documentElement.classList.add('light');
} catch (e) {}
})();
</script>
...
</head>このインライン IIFE は、外部 JS の読み込みより前、CSS の評価が走る前に実行されます。結果、初期描画の段階でライトモードが適用された状態になります。インラインスクリプトは原則避けるべきですが、これは例外的に必要なパターンです。
Variant B ヒーロー — front matter は最小限に
記事ページの上部にある「左にタイトル、右にカバー画像」のヒーローを Variant B と呼んでいます。デザイン段階で 4 案を試して、最終的に B に着地しました。
実装は layouts/partials/article-hero.html の 1 ファイルだけです。設計上のキモは、front matter の追加項目を任意化したことです。
{{/* ---- kicker 解決 ---- */}}
{{ $kicker := .Params.kicker }}
{{ if not $kicker }}
{{ $bits := slice }}
{{ range (first 3 (.Params.categories | default slice)) }}
{{ $bits = $bits | append (upper .) }}
{{ end }}
{{ if lt (len $bits) 3 }}
{{ range (first (sub 3 (len $bits)) (.Params.tags | default slice)) }}
{{ $bits = $bits | append (upper .) }}
{{ end }}
{{ end }}
{{ $kicker = delimit $bits " · " }}
{{ end }}
{{/* ---- subtitle 解決 ---- */}}
{{ $subtitle := .Params.subtitle }}
{{ if not $subtitle }}{{ $subtitle = .Description }}{{ end }}
{{ if not $subtitle }}{{ $subtitle = .Summary | plainify | truncate 140 }}{{ end }}kicker(タイトル上の大文字 mono ラベル)と subtitle(明朝体 italic の副題)は、front matter で書きたければ書ける、書かなければ自動で categories + tags や description から生成される、という二段構えにしました。
これがなぜ大事かと言うと、過去 100 本の記事 front matter を一切書き換えずに新ヒーローへ移行できたからです。新ヒーローを入れたとき、過去記事に kicker: を一括追加するスクリプトを書く必要がなく、既存の categories tags がそのまま流用されました。
また、カバー画像の解決順も同じファイル内で集約しました。
images[0](featured image 的な使い方)coverフロントマター- カテゴリ fallback(
/images/category-<term>.webp)
この優先度を 1 箇所で決めておくことで、baseof.html の OGP 解決ロジックと挙動が揃います。OGP 用画像と記事ヒーロー画像が一致しないという、ありがちな事故を未然に防げました。
記事右の sticky サイドバー — TOC と読書進捗
記事ページには、右側に sticky でついてくるサイドバーを置きました。中身は以下です。
- Reading Progress(読了割合と残り分数)
- 目次 / Contents(h2-h3 から自動生成)
- 著者カード(hugo.toml の
[params.authors.<key>]を参照) - タグ
実装は layouts/partials/article-sidebar.html です。Hugo の .TableOfContents をそのまま使っているので、本文に ## 見出し を書くだけで自動的にサイドバー TOC に流れます。
{{ $readTime := div (len (.Plain | plainify)) 400 }}
{{ if lt $readTime 1 }}{{ $readTime = 1 }}{{ end }}
{{ $authorKey := .Params.author | default "chillarin" }}
{{ $author := index .Site.Params.authors $authorKey }}
{{ if not $author }}{{ $author = index .Site.Params.authors "chillarin" }}{{ end }}
<aside class="a-side" aria-label="記事サイドバー">
...
{{ with .TableOfContents }}
<section class="a-side-card a-side-toc">
<div class="a-side-label">目次 · Contents</div>
{{ . }}
</section>
{{ end }}
...
</aside>読了時間の見積もりは、.Plain の文字数を 400 で割るだけの粗い計算です。日本語と英語が混在するブログでは、単語ベースより文字数ベースのほうが体感に近い数値が出ます。WPM(Words Per Minute)方式は英語ブログ前提なので、そのまま日本語に持ち込まないこと。
読書進捗バー — 32 行で十分
ヘッダー直下に出る読書進捗バーは、reading-progress.js 32 行だけで動いています。
(function () {
var bar = null, ticking = false;
function update() {
ticking = false;
if (!bar) return;
var el = document.scrollingElement || document.documentElement;
var max = el.scrollHeight - el.clientHeight;
var p = max > 0 ? (el.scrollTop / max) : 0;
bar.style.transform = 'scaleX(' + Math.max(0, Math.min(1, p)) + ')';
}
function onScroll() {
if (!ticking) {
window.requestAnimationFrame(update);
ticking = true;
}
}
document.addEventListener('DOMContentLoaded', function () {
bar = document.querySelector('.site-header__progress');
if (!bar) return;
window.addEventListener('scroll', onScroll, { passive: true });
update();
});
})();requestAnimationFrame でフレーム同期し、{ passive: true } で scroll パフォーマンスを落とさないようにしている、それだけです。React で同等のものを書くと「useEffect の依存配列をどうするか」「strict mode で 2 回走る」などで悩むので、ブログのような単純な要件には素の JS のほうが速いことが多いです。
コードブロック・Callout・ドロップキャップ
コードブロック render hook
Markdown のコードフェンスをカスタムレンダリングするため、layouts/_default/_markup/render-codeblock.html を置いています。
{{ $lang := .Type | default "text" }}
{{ $title := $lang }}
{{ if eq $lang "text" }}{{ $title = "snippet" }}{{ end }}
<div class="a-pre" data-lang="{{ $lang }}">
<div class="a-pre-head">
<span class="a-pre-dots" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="a-pre-title">{{ $title }}</span>
<button type="button" class="a-pre-copy" aria-label="コードをコピー">copy</button>
</div>
{{ highlight (trim .Inner "\n") $lang .Options }}
</div>macOS のターミナルウィンドウ風の 3 ドット、言語名タイトル、copy ボタンの 3 点セットを、Markdown 側を一切書き換えずに付与できます。Hugo 標準の highlight 関数(chroma)にハイライトは丸投げ。
Callout / Box ショートコード
引用的に強調したい本文には {{< callout >}}、注意喚起や情報補足には {{< box "warning" >}} を用意しています。Markdown の > 引用記法より目立たせたいが、図や見出しを使うほどでもない、という温度感のブロックに重宝しています。
ドロップキャップは ::first-letter 一発
記事冒頭の段落の最初の 1 文字を大きく見せるドロップキャップは、CSS だけで実装しました。
.a-body > p:first-of-type::first-letter {
font-family: var(--font-serif);
font-weight: 700;
float: left;
font-size: 4.2rem;
line-height: .95;
padding: 6px 14px 0 0;
color: var(--accent);
}JS でラップ要素を生成する必要はなく、::first-letter 疑似要素にスタイルを当てるだけ。記事執筆者は何も意識しなくていい、というのが個人ブログの仕組みとして正しいと思います。
なお、英語の T で始まる段落と日本語の そ で始まる段落では見え方が大きく変わります。日本語フォントの全角文字に font-size: 4.2rem をかけると周囲がかなり食われるので、本文側に text-wrap: pretty を仕込んで折り返しを整えています。
list / terms / games / members の統一
リニューアルでは記事ページだけでなく、一覧系ページも全面的に作り直しました。
list / terms ページの hero-lite
カテゴリページや tag 一覧で使うヒーローは、article-hero-lite.html という別 partial に切り出しました。記事ページと違って中身が記事一覧なので、原寸枠のカバー画像とヘッダーだけのシンプルな構成です。
ここで効いたのは、カテゴリ fallback カバーをダーク/ライト別に持てるようにしたことです。
{{ $darkRel := printf "static/images/category-%s-dark.webp" $slug }}
{{ $lightRel := printf "static/images/category-%s-light.webp" $slug }}
{{ if fileExists $darkRel }}
{{ $coverDark = printf "/images/category-%s-dark.webp" $slug }}
{{ end }}
{{ if fileExists $lightRel }}
{{ $coverLight = printf "/images/category-%s-light.webp" $slug }}
{{ end }}CSS 側で --cover-dark --cover-light を CSS カスタムプロパティとして渡しておき、.light クラスの有無で background-image を切り替えるという仕組みです。ダークモードの濃いめのイラストと、ライトモードの淡いイラストを、同じカテゴリでも別画像で出せます。
fileExists を使うと、ファイルが無いカテゴリは自動的に dark を fallback として使うので、移行時の事故も少なくなりました。
games / members / apps
- Games は hero lite + 1 カラムの wide card レイアウト + canvas 領域。ミニゲームは横長で見せたい都合があり、3 カラムグリッドではなく 1 カラムに絞り込みました
- Members はもともと記事のような構造だったのを、article 構造 + series grid に作り直して、シリーズごとのカード表示に統一
- Apps は新規セクションとして追加(Simple ZTA Access のランディングが第 1 弾)
特定のセクションで「他のセクションと違うレイアウト」を作るより、partial を細かく分けて組み合わせるほうが、長期的にはメンテしやすいというのを再確認しました。
AI 生成カバー画像を 768×432 (16:9) に統一
地味ですが効果が大きかったのが、AI 生成カバー画像の解像度統一です。
これまで news_digest.py(週次ニュースダイジェスト)と generate_article.py(AI 記事ドラフト生成)で、それぞれ 1024×1024 や 1024×576 など別解像度の画像を吐いていました。これを 768×432 の 16:9 に統一しました。
"width": 768,
"height": 432,なぜこのサイズかと言うと、
- OGP で Twitter / Facebook が好むのが 1.91:1 ≒ 16:9
- 記事一覧カードのサムネイルが 16:9
- ヒーローの背景ぼかし + 右側原寸枠も 16:9
つまり、1 枚で OGP / 一覧 / ヒーローの 3 用途を賄うので、解像度を揃えるメリットが大きい。逆に、AI 生成画像で 1024×1024 を吐いてしまうと、ヒーロー右側の原寸枠(現状 1:1 なので合うのですが)以外で必ず上下クロップが発生します。
ハンドオフ文書を残した理由
リニューアル作業の途中で、docs/renewal/design_handoff_article_page/ というディレクトリにハンドオフ文書を作りました。中身は記事ページのデザインを HTML で書き出したスタティックモック、source の素材、README です。
このタイプの文書を残すコストは小さくありませんが、置いておく理由は 2 つあります。
- 将来の自分(または他の AI)が一目で意図を理解できる。半年後に「なぜここは marign-bottom 36px なのか」と聞かれて即答できる人間は、私を含めて存在しません
- 次のリニューアルの起点になる。次のデザイン更新時、Hugo の
partials/を読み解くより HTML モックを開いたほうが速い
逆に、書きすぎると古くなって嘘になるので、「コードと一緒に動くサンプル」と「設計判断のメモ」だけに絞るのが良いと感じています。Figma の細かい数値などは書きません。書いた瞬間からズレ始めます。
ふりかえり — 効いた判断、効かなかった判断
効いた判断
- OKLCH 1 本でアクセントを定義。明度・透明度を独立に動かせるので、
--accent-soft--accent-line--accent-glowの派生が極めて素直に書けた :rootと:root.lightの二系統で済ませた。コンポーネント CSS からprefers-color-schemeの上書きを排除できた- front matter の新フィールドを optional 化。過去 100 本の記事を一切触らずに新ヒーローへ移行できた
- render hook + 自動ドロップキャップ。記事側を書き換えずに、Markdown を書くだけで新デザインに乗せられた
- AI 生成画像を 16:9 で統一。OGP / 一覧 / ヒーローを 1 枚で賄えるようになった
効かなかった判断
- ヒーロー右側の原寸枠を 1:1 にしたこと。当初は装飾的に「正方形のカード」で見せたかったが、結果的にカバー画像 16:9 と aspect-ratio が一致せず上下クロップが発生する。次のマイナーアップデートで 16:9 に揃える予定です
- Legacy alias を残したこと。
--text-main--text-mutedを--text--mutedのエイリアスとして残しましたが、結局使い続けてしまい、消すタイミングを逃しました。最初から「移行期間 1 ヶ月」と決めて消すべきでした - JS の遅延読み込み未対応。
mode-toggle.jsもreading-progress.jsも小さいですが、defer属性の付与と読み込みタイミングの整理は次の改善余地
全体として
リニューアル v2 は「Hugo + 自作 CSS + JS 2 本」で完結しました。React や Tailwind や SaaS テーマを引っ張ってこなくても、設計トークンが正しく整理されていれば、両モード対応のヒーローと読書サイドバーは作れます。
逆に言えば、最初の tokens.css 設計でつまずくと、その後の作業がすべて遅くなる。リニューアル工数のうち、半分は tokens.css の試行錯誤に消えました。それでも、後半の partials / shortcode / JS 実装は、トークンが固まっていたおかげで一気に進みました。
個人ブログのリニューアルを検討中の方は、まず「色とフォントとスペーシングのトークンを 50 行以内に書けるか」を試してみることをおすすめします。書けるなら、その先は速いです。書けないなら、まだ既存 CSS を整理するフェーズです。
まとめ
ちらりんブログのリニューアル v2 は、以下の構成で実現しました。
tokens.css55 行 + OKLCH ベースのデザイントークン:root/:root.light二系統でダーク・ライト両対応- Variant B ヒーロー partial(front matter は optional)
- 読書サイドバー partial(TOC + 読了時間 + 著者 + タグ)
- 読書進捗バー JS(32 行)+ モードトグル JS(33 行)
- コードブロック render hook + Callout / Box ショートコード + 自動ドロップキャップ
- list / terms / games / members / apps のレイアウト統一
- AI 生成カバーを 768×432 (16:9) に統一
次の課題は、ヒーロー右側枠の 16:9 化、Legacy alias の段階的廃止、JS の defer 化あたりです。これらが片付いたら、v2 はようやく「完成」と言えるかもしれません。
設計トークンの整理が一度終わってしまえば、ブログの見た目を 1 行の CSS 変数で大きく変えられるようになります。リニューアルは終わりではなく、変えやすい状態にする作業だと思っています。
自宅サーバ運用の全体像は 自宅サーバ運用の完全ガイド — Proxmox + Cloudflare Tunnel + Docker で個人ブログを公開し続ける にまとめています。Proxmox クラスタ・公開経路・Docker 本番化・障害対応までを 1 ページで通読できる Pillar ガイドです。
コメント