2026.10.13お知らせ
夜に読む方のために、暗い配色を用意しました
画面の明るさがつらい夜でも読みやすいよう、背景を暗くした配色に切り替えられるようにしました。はじめは端末の設定に合わせて表示し、切り替えた場合はその選択を覚えておきます。
いまの配色(ライト)
- 背景#ffffff
- 本文#1c1917
- 補足#57534e
- 罫線#e7e5e4
- アクセント#d97706
背景に対するコントラスト比 本文 17.5:1・補足 7.6:1
スイッチを押すと、押した位置から円が広がって画面の配色がライトとダークで入れ替わるダークモード切り替え。最初は OS の設定(prefers-color-scheme)に合わせ、切り替えた配色は localStorage に保存して次に開いたときも使います。保存中は「OS の設定に戻す」で選択を消せます。円の広がりは View Transitions で作り、非対応のブラウザや prefers-reduced-motion では即時に切り替えるので、どちらでも内容はそのまま読めます。キーボードで押したときはスイッチの中心から広がり、操作はキーボードだけで完結します。スイッチは button role="switch" で、切り替えた結果は aria-live で読み上げます。見本の横に OS の設定・保存した選択・いまの表示・切り替え方を並べ、記事の中にはいまの配色の色見本とコントラスト比を出します。依存ライブラリなし。角丸・英字の小見出し・末尾の矢印リンクは使わず、hairline 罫線と余白で組んでいます。
見本の上端のスイッチを押すと、押した位置から円が広がって暗い配色に変わります。はじめは OS の設定に合わせ、切り替えた配色はこの端末に保存するので、次に開いたときもそのままです。
First CH読みもの
OS の設定(ライト)に合わせて表示しています
2026.10.13お知らせ
画面の明るさがつらい夜でも読みやすいよう、背景を暗くした配色に切り替えられるようにしました。はじめは端末の設定に合わせて表示し、切り替えた場合はその選択を覚えておきます。
背景に対するコントラスト比 本文 17.5:1・補足 7.6:1
"use client";
import { useId, useRef, useState, useSyncExternalStore, type CSSProperties, type MouseEvent } from "react";
import { flushSync } from "react-dom";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({ weight: ["500", "600"], subsets: ["latin"], display: "swap" });
/**
* ダークモード切り替え(押した位置から円形に広がる)
*
* 差別化: 既存のUI部品は1つの部品の中で状態が変わるもの(cookie-consent-banner の同意スイッチ、product-configurator-3d の部位の色替え)で、
* 画面全体の配色を入れ替えて保存する部品は無かった。本ブロックは OS の設定・保存した選択・いまの表示の3つの関係を見せるのが主役。
* 角丸・英字の小見出し・末尾の矢印リンク・読み込み時の出現アニメは置かない。
*
* 汎用技法メモ:
* 1. **円の広がりは View Transitions**。document.startViewTransition の中で配色を同期的に書き換え(flushSync)、
* transition.ready のあとに documentElement.animate の pseudoElement で ::view-transition-new(名前) の clip-path を
* circle(0) から画面の一番遠い角まで広げる。半径は押した点から4隅までの距離の最大値。
* 2. **広げる範囲を部品に閉じる**。ページ全体(root)ではなく舞台の要素にだけ、遷移の間だけ view-transition-name を付ける。
* 名前は useId から作って重複させない。animate() の pseudoElement は小文字に直されてから照合されるため、
* 大文字を含む名前(useId の既定)だと存在しない擬似要素を狙って円が出ない。名前は必ず小文字にする。old/new の既定のクロスフェードは <style> で止める(animation: none)。
* 実案件でページ全体を切り替えるなら名前を付けずに ::view-transition-new(root) を同じ手順で動かす。
* 配色の CSS 変数を持つ要素は @container(サイズコンテナ)にしない。ヘッドレス Chrome の撮影で、
* 変数を差し替えたあと子の文字色・罫線だけが古い色のまま写ることがあった。
* 3. **円の中心**は pointer の clientX/Y を要素の左上からの距離にし、親に transform の縮小が掛かっていれば
* rect.width ÷ offsetWidth で割り戻す。キーボードで押したとき(click の detail が 0)はスイッチの中心から広げる。
* 4. **非対応・動きを減らす設定では即時に切り替える**。startViewTransition が無い、または prefers-reduced-motion: reduce なら
* 配色を書き換えるだけ。clip-path を動かすのは遷移中の擬似要素だけで、部品そのものの CSS で動かすのは transform と opacity だけ。
* 5. **初期値は OS、選んだら保存**。保存した選択(localStorage の1キー)と OS の設定(matchMedia)を
* どちらも useSyncExternalStore で読み、保存が無ければ OS に従う。OS 側の変更にも追従する。
* 本番では <head> の小さな同期スクリプトで描画前に data-theme を付け、読み込み直後の白い一瞬を出さない。
*
* 文言・配色はすべて First CH のオリジナル。
*/
type Theme = "light" | "dark";
const KEY = "fc-theme";
const CHANGE = "fc-theme-change";
const DARK_QUERY = "(prefers-color-scheme: dark)";
const REDUCE_QUERY = "(prefers-reduced-motion: reduce)";
const PALETTE: Record<Theme, { name: string; tokens: { id: string; label: string; hex: string }[] }> = {
light: {
name: "ライト",
tokens: [
{ id: "bg", label: "背景", hex: "#ffffff" },
{ id: "fg", label: "本文", hex: "#1c1917" },
{ id: "muted", label: "補足", hex: "#57534e" },
{ id: "line", label: "罫線", hex: "#e7e5e4" },
{ id: "accent", label: "アクセント", hex: "#d97706" },
],
},
dark: {
name: "ダーク",
tokens: [
{ id: "bg", label: "背景", hex: "#17140f" },
{ id: "fg", label: "本文", hex: "#f3efe8" },
{ id: "muted", label: "補足", hex: "#b3aca3" },
{ id: "line", label: "罫線", hex: "#3b352d" },
{ id: "accent", label: "アクセント", hex: "#f59e0b" },
],
},
};
const hex = (t: Theme, id: string) => PALETTE[t].tokens.find((k) => k.id === id)!.hex;
// WCAG 2 の相対輝度とコントラスト比
const luminance = (h: string) => {
const [r, g, b] = [1, 3, 5].map((i) => {
const c = parseInt(h.slice(i, i + 2), 16) / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const contrast = (a: string, b: string) => {
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
const readStored = (): Theme | null => {
try {
const v = localStorage.getItem(KEY);
return v === "light" || v === "dark" ? v : null;
} catch {
return null;
}
};
const writeStored = (v: Theme | null) => {
try {
if (v) localStorage.setItem(KEY, v);
else localStorage.removeItem(KEY);
} catch {
// 保存できない環境では、このページを開いている間だけ切り替える
}
window.dispatchEvent(new Event(CHANGE));
};
const subscribeStored = (cb: () => void) => {
window.addEventListener("storage", cb);
window.addEventListener(CHANGE, cb);
return () => {
window.removeEventListener("storage", cb);
window.removeEventListener(CHANGE, cb);
};
};
const mediaStore = (query: string) => ({
subscribe: (cb: () => void) => {
const mq = window.matchMedia(query);
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
},
get: () => window.matchMedia(query).matches,
});
const darkStore = mediaStore(DARK_QUERY);
const reduceStore = mediaStore(REDUCE_QUERY);
const noop = () => () => {};
const hasViewTransition = () => typeof document.startViewTransition === "function";
const serverFalse = () => false;
const serverNull = () => null;
const focusRing =
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--tsr-accent)";
export default function ThemeSwitchReveal() {
// 保存できない環境の控え(その場合も切り替え自体は効かせる)
const [fallback, setFallback] = useState<Theme | null>(null);
const storedRaw = useSyncExternalStore(subscribeStored, readStored, serverNull);
const osDark = useSyncExternalStore(darkStore.subscribe, darkStore.get, serverFalse);
const reduce = useSyncExternalStore(reduceStore.subscribe, reduceStore.get, serverFalse);
const vtSupported = useSyncExternalStore(noop, hasViewTransition, serverFalse);
const [announce, setAnnounce] = useState("");
const stored = storedRaw ?? fallback;
const os: Theme = osDark ? "dark" : "light";
const theme: Theme = stored ?? os;
const isDark = theme === "dark";
const stageRef = useRef<HTMLDivElement>(null);
const switchRef = useRef<HTMLButtonElement>(null);
const tokenRef = useRef(0);
// animate() の pseudoElement は小文字に直されて照合されるので、名前も小文字にそろえる
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "").toLowerCase();
const vtName = `tsr-${uid}`;
const labelId = `${uid}-label`;
const stateId = `${uid}-state`;
const commit = (next: Theme | null) => {
writeStored(next);
if (readStored() !== next) setFallback(next);
else setFallback(null);
};
const say = (text: string) => {
setAnnounce("");
requestAnimationFrame(() => setAnnounce(text));
};
// 配色を入れ替える。from は円の中心(ビューポート座標)
const change = (next: Theme | null, from: { x: number; y: number }) => {
const stage = stageRef.current;
const nextTheme = next ?? os;
const animate =
stage && nextTheme !== theme && hasViewTransition() && !window.matchMedia(REDUCE_QUERY).matches;
if (!animate) {
commit(next);
return;
}
const token = ++tokenRef.current;
stage.style.viewTransitionName = vtName;
const transition = document.startViewTransition(() => {
flushSync(() => commit(next));
});
transition.ready
.then(() => {
const rect = stage.getBoundingClientRect();
const scale = rect.width / stage.offsetWidth || 1;
const w = stage.offsetWidth;
const h = stage.offsetHeight;
const x = Math.min(Math.max((from.x - rect.left) / scale, 0), w);
const y = Math.min(Math.max((from.y - rect.top) / scale, 0), h);
const r = Math.hypot(Math.max(x, w - x), Math.max(y, h - y));
document.documentElement.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${r}px at ${x}px ${y}px)`] },
{
duration: 620,
easing: "cubic-bezier(0.22, 0.7, 0.2, 1)",
pseudoElement: `::view-transition-new(${vtName})`,
},
);
})
.catch(() => {});
transition.finished.finally(() => {
if (tokenRef.current === token) stage.style.viewTransitionName = "";
});
};
const centerOf = (el: Element | null) => {
const r = el?.getBoundingClientRect();
return r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : { x: 0, y: 0 };
};
const originOf = (e: MouseEvent<HTMLButtonElement>) =>
e.detail === 0 ? centerOf(e.currentTarget) : { x: e.clientX, y: e.clientY };
const toggle = (e: MouseEvent<HTMLButtonElement>) => {
const next: Theme = isDark ? "light" : "dark";
change(next, originOf(e));
say(`${PALETTE[next].name}に切り替えました。この選択はこの端末に保存します。`);
};
const followOs = (e: MouseEvent<HTMLButtonElement>) => {
change(null, originOf(e));
// 押したボタンは消えるので、フォーカスはスイッチへ戻す
requestAnimationFrame(() => switchRef.current?.focus());
say(`保存した選択を消しました。OS の設定に合わせて${PALETTE[os].name}で表示します。`);
};
const fg = hex(theme, "fg");
const bg = hex(theme, "bg");
const method = !vtSupported
? "即時(このブラウザは円の広がりに非対応)"
: reduce
? "即時(動きを減らす設定が有効)"
: "押した位置から円で広げる(View Transitions)";
const stageVars = Object.fromEntries(
PALETTE[theme].tokens.map((t) => [`--tsr-${t.id}`, t.hex]),
) as CSSProperties;
return (
<section className="@container w-full max-w-5xl bg-white text-gray-900">
<style>{`::view-transition-old(${vtName}),::view-transition-new(${vtName}){animation:none;mix-blend-mode:normal}`}</style>
<div className="grid gap-x-8 gap-y-6 @2xl:grid-cols-[minmax(0,1fr)_16rem] @2xl:grid-rows-[auto_1fr]">
<header className="@2xl:col-start-2 @2xl:row-start-1">
<h3 className="text-[clamp(1.4rem,3.4vw,2rem)] leading-[1.4] font-bold tracking-tight @2xl:text-[1.4rem]">
押した所から広がる
<br />
ダークモード切り替え
</h3>
<p className="mt-4 border-b border-gray-200 pb-6 text-[14px] leading-[1.9] font-medium text-gray-600">
見本の上端のスイッチを押すと、押した位置から円が広がって暗い配色に変わります。はじめは OS の設定に合わせ、切り替えた配色はこの端末に保存するので、次に開いたときもそのままです。
</p>
</header>
{/* 舞台: 配色を持つのはこの箱だけ。本番ではこの役を html 要素が担う */}
<div
ref={stageRef}
data-theme={theme}
style={{ ...stageVars, colorScheme: theme }}
className="flex flex-col border border-(--tsr-line) bg-(--tsr-bg) text-(--tsr-fg) @2xl:col-start-1 @2xl:row-span-2 @2xl:row-start-1"
>
<div className="flex items-center justify-between gap-4 border-b border-(--tsr-line) px-4 py-3 @md:px-6">
<p className={`${montserrat.className} text-[15px] font-semibold tracking-tight whitespace-nowrap`}>
First CH<span className="ml-2 text-[12px] font-medium text-(--tsr-muted)">読みもの</span>
</p>
<button
ref={switchRef}
type="button"
role="switch"
aria-checked={isDark}
aria-labelledby={labelId}
aria-describedby={stateId}
onClick={toggle}
className={`group flex shrink-0 cursor-pointer items-center gap-2.5 py-1 ${focusRing}`}
>
<span id={labelId} className="text-[12.5px] font-bold whitespace-nowrap">
ダークモード
</span>
<span
aria-hidden="true"
className="relative h-6 w-11 border border-(--tsr-muted) bg-(--tsr-bg) group-hover:border-(--tsr-accent)"
>
<span
className="absolute top-[3px] left-[3px] grid size-4 place-items-center bg-(--tsr-accent) text-(--tsr-bg) transition-transform duration-200 ease-out motion-reduce:transition-none"
style={{ transform: isDark ? "translateX(20px)" : "translateX(0)" }}
>
<svg viewBox="0 0 16 16" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.8">
{isDark ? (
<path d="M10.8 2.6a5.6 5.6 0 1 0 2.6 7.2 4.4 4.4 0 0 1-2.6-7.2Z" fill="currentColor" stroke="none" />
) : (
<>
<circle cx="8" cy="8" r="2.6" fill="currentColor" stroke="none" />
<path d="M8 1.6v1.6M8 12.8v1.6M1.6 8h1.6M12.8 8h1.6M3.5 3.5l1.1 1.1M11.4 11.4l1.1 1.1M3.5 12.5l1.1-1.1M11.4 4.6l1.1-1.1" />
</>
)}
</svg>
</span>
</span>
</button>
</div>
<p
id={stateId}
className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-(--tsr-line) bg-(--tsr-bg) px-4 py-2 text-[11.5px] text-(--tsr-muted) @md:px-6"
>
{stored ? (
<>
<span>{PALETTE[stored].name}を選んで保存しています</span>
<button
type="button"
onClick={followOs}
className={`cursor-pointer font-bold text-(--tsr-fg) underline decoration-(--tsr-line) underline-offset-4 hover:decoration-(--tsr-accent) ${focusRing}`}
>
OS の設定に戻す
</button>
</>
) : (
<span>OS の設定({PALETTE[os].name})に合わせて表示しています</span>
)}
</p>
<article className="flex-1 px-4 pt-6 pb-7 @md:px-6">
<p className="flex items-center gap-3 text-[11.5px] text-(--tsr-muted)">
<span className={`${montserrat.className} tabular-nums`}>2026.10.13</span>
<span className="border-l border-(--tsr-line) pl-3 font-bold text-(--tsr-accent)">お知らせ</span>
</p>
<h4 className="mt-3 text-[1.15rem] @md:text-[1.35rem] leading-[1.5] font-bold tracking-tight">
夜に読む方のために、暗い配色を用意しました
</h4>
<p className="mt-3 text-[13.5px] leading-[1.95] text-(--tsr-muted)">
画面の明るさがつらい夜でも読みやすいよう、背景を暗くした配色に切り替えられるようにしました。はじめは端末の設定に合わせて表示し、切り替えた場合はその選択を覚えておきます。
</p>
<h5 className="mt-6 text-[12px] font-bold">いまの配色({PALETTE[theme].name})</h5>
<ul className="mt-2 border-t border-(--tsr-line)">
{PALETTE[theme].tokens.map((t) => (
<li
key={t.id}
className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-3 border-b border-(--tsr-line) py-2 text-[12.5px]"
>
<span aria-hidden="true" className="size-3.5 border border-(--tsr-line)" style={{ background: t.hex }} />
<span>{t.label}</span>
<span className={`${montserrat.className} text-[11.5px] text-(--tsr-muted) tabular-nums`}>
{t.hex}
</span>
</li>
))}
</ul>
<p className="mt-3 text-[11.5px] leading-[1.8] text-(--tsr-muted)">
背景に対するコントラスト比 本文{" "}
<span className={`${montserrat.className} font-semibold text-(--tsr-fg) tabular-nums`}>
{contrast(fg, bg).toFixed(1)}:1
</span>
・補足{" "}
<span className={`${montserrat.className} font-semibold text-(--tsr-fg) tabular-nums`}>
{contrast(hex(theme, "muted"), bg).toFixed(1)}:1
</span>
</p>
</article>
</div>
<div className="space-y-6 @2xl:col-start-2 @2xl:row-start-2">
<dl className="border-t border-gray-200 text-[12.5px]">
{[
["OS の設定", PALETTE[os].name],
["保存した選択", stored ? PALETTE[stored].name : "なし"],
["いまの表示", PALETTE[theme].name],
["切り替え方", method],
].map(([k, v]) => (
<div key={k} className="grid grid-cols-[6.5rem_minmax(0,1fr)] gap-x-3 border-b border-gray-200 py-2">
<dt className="text-gray-500">{k}</dt>
<dd className="font-bold">{v}</dd>
</div>
))}
</dl>
<ol className="space-y-2.5 text-[12.5px] leading-[1.8] text-gray-600">
{[
"最初は OS の設定に合わせる。保存した選択があればそちらを優先する",
"選択は localStorage の1キーに保存し、「OS の設定に戻す」で消す",
"円は押した位置から広げる。キーボードで押したときはスイッチの中心から",
"円の広がりに非対応のブラウザや、動きを減らす設定では即時に切り替える",
].map((t, i) => (
<li key={t} className="grid grid-cols-[1.5rem_minmax(0,1fr)]">
<span className={`${montserrat.className} font-semibold text-amber-700 tabular-nums`}>
{String(i + 1).padStart(2, "0")}
</span>
<span>{t}</span>
</li>
))}
</ol>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-2 border-t border-gray-200 pt-4 text-[12px] text-gray-600">
<dt>
<kbd className="border border-gray-200 bg-gray-50 px-1.5 text-[10.5px]">Tab</kbd>
</dt>
<dd>スイッチへ移る</dd>
<dt>
<kbd className="border border-gray-200 bg-gray-50 px-1.5 text-[10.5px]">Space</kbd>{" "}
<kbd className="border border-gray-200 bg-gray-50 px-1.5 text-[10.5px]">Enter</kbd>
</dt>
<dd>配色を切り替える</dd>
</dl>
</div>
</div>
<p role="status" aria-live="polite" aria-atomic="true" className="sr-only">
{announce}
</p>
</section>
);
}
npx shadcn@latest add https://designs.first-ch.com/r/theme-switch-reveal.json