BLK-97Blocks

ダークモード切り替え(押した位置から円形に広がる)

スイッチを押すと、押した位置から円が広がって画面の配色がライトとダークで入れ替わるダークモード切り替え。最初は OS の設定(prefers-color-scheme)に合わせ、切り替えた配色は localStorage に保存して次に開いたときも使います。保存中は「OS の設定に戻す」で選択を消せます。円の広がりは View Transitions で作り、非対応のブラウザや prefers-reduced-motion では即時に切り替えるので、どちらでも内容はそのまま読めます。キーボードで押したときはスイッチの中心から広がり、操作はキーボードだけで完結します。スイッチは button role="switch" で、切り替えた結果は aria-live で読み上げます。見本の横に OS の設定・保存した選択・いまの表示・切り替え方を並べ、記事の中にはいまの配色の色見本とコントラスト比を出します。依存ライブラリなし。角丸・英字の小見出し・末尾の矢印リンクは使わず、hairline 罫線と余白で組んでいます。

追加日:
2026-10-13
依存:
なし
tags
#dark-mode #theme #toggle #switch #view-transitions #prefers-color-scheme #localstorage #keyboard #accessibility #ui #no-image

プレビュー

押した所から広がる
ダークモード切り替え

見本の上端のスイッチを押すと、押した位置から円が広がって暗い配色に変わります。はじめは OS の設定に合わせ、切り替えた配色はこの端末に保存するので、次に開いたときもそのままです。

First CH読みもの

OS の設定(ライト)に合わせて表示しています

2026.10.13お知らせ

夜に読む方のために、暗い配色を用意しました

画面の明るさがつらい夜でも読みやすいよう、背景を暗くした配色に切り替えられるようにしました。はじめは端末の設定に合わせて表示し、切り替えた場合はその選択を覚えておきます。

いまの配色(ライト)
  • 背景#ffffff
  • 本文#1c1917
  • 補足#57534e
  • 罫線#e7e5e4
  • アクセント#d97706

背景に対するコントラスト比 本文 17.5:1・補足 7.6:1

OS の設定
ライト
保存した選択
なし
いまの表示
ライト
切り替え方
即時(このブラウザは円の広がりに非対応)
  1. 01最初は OS の設定に合わせる。保存した選択があればそちらを優先する
  2. 02選択は localStorage の1キーに保存し、「OS の設定に戻す」で消す
  3. 03円は押した位置から広げる。キーボードで押したときはスイッチの中心から
  4. 04円の広がりに非対応のブラウザや、動きを減らす設定では即時に切り替える
Tab
スイッチへ移る
Space Enter
配色を切り替える

"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>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/theme-switch-reveal.json