BLK-97Blocks

Dark Mode Toggle (circular reveal from the click point)

A dark mode toggle that swaps the color scheme between light and dark with a circle that grows from the point you clicked. It starts from the operating system setting (prefers-color-scheme), and once you switch, the choice is stored in localStorage and used the next time the page opens. While a choice is stored, Follow system setting clears it. The circle is drawn with View Transitions. Browsers without them, and visitors who prefer reduced motion, get an instant switch, so the content reads the same either way. Pressing the switch from the keyboard grows the circle from the center of the switch, and everything works without a mouse. The switch is a button with role="switch", and the result is announced through aria-live. Next to the demo, a small ledger shows the system setting, the stored choice, the current scheme and how the switch will animate, and the sample article lists the current palette with its contrast ratios. No dependencies, no rounded corners, no small English kickers or trailing arrow links, just hairline rules and whitespace.

Added:
2026-10-13
Dependencies:
None
tags
#dark-mode #theme #toggle #switch #view-transitions #prefers-color-scheme #localstorage #keyboard #accessibility #ui #no-image

Preview

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

見本の上端のスイッチを押すと、押した位置から円が広がって暗い配色に変わります。はじめは 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>
  );
}

Add to your project via shadcn CLI

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