TECH · HUGO · DESIGN

ちらりんブログ リニューアル v2 の裏側 — OKLCH トークンとダーク/ライト両対応を Hugo で実装した話

tokens.css 一枚と JS 二本で、SaaS テーマに頼らず両モード対応のブログを組み直した話

tech 2026-04-26 58 min read by ちらりん
cover · 1024×1024

はじめに

ちらりんブログを 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 です。サイト全体の色とフォントは、ここで決まります。

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 です。

js
(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 を読みます。

html
<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 の追加項目を任意化したことです。

go-html-template
{{/* ---- 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 + tagsdescription から生成される、という二段構えにしました。

これがなぜ大事かと言うと、過去 100 本の記事 front matter を一切書き換えずに新ヒーローへ移行できたからです。新ヒーローを入れたとき、過去記事に kicker: を一括追加するスクリプトを書く必要がなく、既存の categories tags がそのまま流用されました。

また、カバー画像の解決順も同じファイル内で集約しました。

  1. images[0](featured image 的な使い方)
  2. cover フロントマター
  3. カテゴリ 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 に流れます。

go-html-template
{{ $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 行だけで動いています。

js
(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 を置いています。

go-html-template
{{ $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 だけで実装しました。

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 カバーをダーク/ライト別に持てるようにしたことです。

go-html-template
{{ $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 に統一しました。

python
"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 つあります。

  1. 将来の自分(または他の AI)が一目で意図を理解できる。半年後に「なぜここは marign-bottom 36px なのか」と聞かれて即答できる人間は、私を含めて存在しません
  2. 次のリニューアルの起点になる。次のデザイン更新時、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.jsreading-progress.js も小さいですが、defer 属性の付与と読み込みタイミングの整理は次の改善余地

全体として

リニューアル v2 は「Hugo + 自作 CSS + JS 2 本」で完結しました。React や Tailwind や SaaS テーマを引っ張ってこなくても、設計トークンが正しく整理されていれば、両モード対応のヒーローと読書サイドバーは作れます。

逆に言えば、最初の tokens.css 設計でつまずくと、その後の作業がすべて遅くなる。リニューアル工数のうち、半分は tokens.css の試行錯誤に消えました。それでも、後半の partials / shortcode / JS 実装は、トークンが固まっていたおかげで一気に進みました。

個人ブログのリニューアルを検討中の方は、まず「色とフォントとスペーシングのトークンを 50 行以内に書けるか」を試してみることをおすすめします。書けるなら、その先は速いです。書けないなら、まだ既存 CSS を整理するフェーズです。


まとめ

ちらりんブログのリニューアル v2 は、以下の構成で実現しました。

  • tokens.css 55 行 + 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 ガイドです。

· · ·

コメント