@k8ordo/color-scheme

仕組み

Provider が何を材料に、いつ、どう決めているかを説明します。1 つの規則、それを最初の描画の前に当てるインラインスクリプト、その後の追従、設定が保存される行、そこから導かれる保証、そしてテストの書き方です。

1 つの規則

訪問者が選んだものが最優先で、次に Provider の defaultPreference、それが 'system' ならシステムに尋ねます。画面に出るのは、この規則で解決した scheme だけです。

保存された preferencedefaultPreference(prefers-color-scheme: dark)scheme
'dark'どれでもどれでも'dark'
'light'どれでもどれでも'light'
なし'dark'どれでも'dark'
なし'light'どれでも'light'
なし'system'一致する'dark'
なし'system'一致しない'light'

保存行が JSON のオブジェクトでないときも、preference'light' でも 'dark' でもないとき(たとえば {"preference":"sepia"}{})も、「なし」として読みます。インラインスクリプトもストアも同じで、行にあるほかのフィールドはどちらも読みません。

「なし」は、初回訪問時にシステムが答えた値を保存したものではありません。既定値が 'system' なら、選んでいない訪問者は、あとで OS の設定を変えてもそれに従います。

このページで見る

このページが今読んでいる材料と、その結果です。このサイトのルートレイアウトは defaultPreference を渡していないので 'system' です。ヘッダーの切替を押す、OS の設定を変える、別のタブでこのサイトの設定を変える、のどれでも、対応する行がその場で変わります。localStorage.getItem の行は、一度も選んでいなければ null'system' に戻したあとは '{}' です。 'system' に戻す選択肢は、@k8ordo/color-scheme のトップにある実演で試せます。

材料

matchMedia('(prefers-color-scheme: dark)').matches
ブラウザで読みます
localStorage.getItem('k8ordo-state:color-scheme')
ブラウザで読みます
useAppState(colorSchemeState)
ブラウザで読みます

結果

useColorScheme().preference
ブラウザで読みます
useColorScheme().scheme
ブラウザで読みます
document.documentElement.classList.contains('dark')
ブラウザで読みます

最初の描画の前

React が動くのは JavaScript が読み込まれてからで、その時点でブラウザはもう描画しているかもしれません。クラスを付けるのが effect だけなら、ページはまず既定値で描かれてから切り替わり、ダークを選んだ訪問者にはライトの画面が一瞬光ります。そこで Provider は同じ規則をもう一度インラインスクリプトとして書き、最初の子として描きます。

スクリプトが読むもの

  • colorSchemeState.inlineRead() で読む k8ordo-state:color-scheme の行。ストアが書くのと同じ行です。
  • 行の preference'light''dark' のどちらかならその値、そうでなければ Provider に渡した defaultPreference。既定値はスクリプトの文字列に埋め込まれます。ここではスキーマが走らないので、値は手で確かめています。
  • こうして決まった値が 'system' のときだけ、matchMedia('(prefers-color-scheme: dark)') の結果。
  • 結果がダークなら document.documentElement.classList.add('dark')。クラスを外すことはなく、そのあとは何もしません。

defaultPreference'system' のときに HTML に書かれるスクリプトを、読みやすく整形したものです。

(() => {
  const s = (() => {
    try {
      const v = JSON.parse(localStorage.getItem('k8ordo-state:color-scheme'));
      return v !== null && typeof v === 'object' && !Array.isArray(v) ? v : null;
    } catch {
      return null;
    }
  })();
  const v = s && s.preference;
  const p = v === 'dark' || v === 'light' ? v : 'system';
  if (
    p === 'dark' ||
    (p !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches)
  )
    document.documentElement.classList.add('dark');
})();

hydrate のとき、React はこの <script> 要素をそのまま引き継ぎ、もう一度実行することはありません。

インラインスクリプトなので、インラインスクリプトを禁じる Content Security Policy の下では実行されません。Provider は nonce を受け取りません。

サーバーが描くもの

サーバーには localStorage も、尋ねるシステムもありません。Provider は「何も保存されておらず、システムはライト」として描きます。preference'system'schemedefaultPreference(それが 'system' なら 'light')です。HTML の <html>dark クラスは無く、付けるのはスクリプトです。Cookie やヘッダーで先回りして推測することはしません。

クラスを読む CSS は最初の描画から正しく出ます。一方、scheme から選んだマークアップ(アイコンやラベル)は、hydrate されるまでサーバーの値を表示します。最初から正しくなければならないものは、両方を描いて CSS で片方を隠します。

// src/components/scheme-toggle.tsx
'use client';

import { useColorScheme } from '@k8ordo/color-scheme';

export function SchemeToggle() {
  const { scheme, setPreference } = useColorScheme();

  return (
    <button
      onClick={() => {
        setPreference(scheme === 'dark' ? 'light' : 'dark');
      }}
      type="button"
    >
      <span className="dark:hidden">Switch to dark</span>
      <span className="hidden dark:inline">Switch to light</span>
    </button>
  );
}

この例は、クラスを読む dark: バリアント(@k8ordo/ui の tailwind.css が宣言するもの)を前提にしています。Tailwind CSS 4 の既定の dark:prefers-color-scheme を読みます。 Get Started: クラスでスタイルを当てる

CSS で出し分けられないものは、React の use(browser())browserreact-dom から)を <Suspense> の下で使うと、サーバーの HTML に含めずに済みます。サーバーは推測を書く代わりに fallback を書き、中身はブラウザで描かれます。 @k8ordo/static: ブラウザが必要なコンポーネント

その後の追従

hydrate したあと、クラスを書くのは Provider だけです。材料のどれかが変わるたびに規則で解決し直し、scheme が変わったら effect で <html>dark を付け外しします。

  • 訪問者が選んだとき: setPreference がストアを更新します。新しい値はすぐ次の描画に反映され、localStorage への書き込みはその直後にまとめて行われます。
  • システムが変わったとき: Provider は matchMedia('(prefers-color-scheme: dark)')change を購読しています。何も選ばれておらず既定値が 'system' なら、ページを開いたまま OS の設定を変えても、どちら向きにも追従します。
  • 別のタブで変わったとき: @k8ordo/state のローカル状態は、このキーの storage イベントを購読しています。別のタブで選んだ設定も、別のタブで localStorage を消したことも、このタブの Provider に届きます。
  • hydrate のとき: hydrate する描画はサーバーと同じ推測を読み、何も書きません。クラスを書くのは、その直後にストアを読む描画です。スクリプトが付けたクラスが途中で外れることはありません。

同じタブで行を直接書き換えない

storage イベントは、書き込んだタブ自身には届きません。同じタブで localStorage.setItem('k8ordo-state:color-scheme', …) と直接書いても、Provider は再読み込みまで気づきません。変えるときは setPreference を通します。

Provider は 1 つ

Provider はルートレイアウトに 1 つだけ置きます。Provider はそれぞれがスクリプトを描いてクラスを書くので、defaultPreference の違う Provider が 2 つあれば食い違います。useColorScheme() が読むのは一番近い Provider です。

保存先は colorSchemeState

設定は @k8ordo/state のふつうのローカル状態として保存されます。その定義が colorSchemeState として export されており、中身はこれですべてです。

import { defineLocalState } from '@k8ordo/state';
import * as z from 'zod/mini';

export const colorSchemeState = defineLocalState(
  'color-scheme',
  z.object({ preference: z.optional(z.enum(['light', 'dark'])) }),
);

キーは color-scheme なので、localStorage のキーは k8ordo-state:color-scheme です(colorSchemeState.storageKey)。preference は省略可能で、省略されていることが「選んでいない」を表します。

操作保存される行
一度も選んでいない行なし(getItemnull
setPreference('dark'){"preference":"dark"}
setPreference('light'){"preference":"light"}
setPreference('system'){}

'system' に戻しても行は消えず、preference の無い {} が残ります。preference を読む側にとっては、行が無いのと同じです。

ほかの場所から読む

hook を通さずに行を読みたいときは、どのクライアントコンポーネントからでも useAppState(colorSchemeState) を呼べます。@k8ordo/state には Provider が無く、ストアはキーごとに 1 つなので、<ColorSchemeProvider> と同じ値を読みます。

// src/components/stored-preference.tsx
'use client';

import { colorSchemeState } from '@k8ordo/color-scheme';
import { useAppState } from '@k8ordo/state';

export function StoredPreference() {
  const [{ preference }] = useAppState(colorSchemeState);

  return <output>{preference ?? 'system'}</output>;
}

返るのは保存された preference'light' | 'dark' | undefined)で、解決した scheme ではありません。画面に出ているものが欲しいなら useColorScheme() を使います。サーバーでの描画と hydrate の描画では、何も保存されていないときの値 undefined を返します。

最初の描画の前に同じ行を読みたい自前のインラインスクリプトには colorSchemeState.inlineRead() があります。保存されたオブジェクト(読めなければ null)に評価される JavaScript の式を返します。スキーマは走らないので、使うフィールドは自分で確かめます。 @k8ordo/state: ハイドレーション前に読む

アプリで defineLocalState('color-scheme', …) を別に定義しないでください。@k8ordo/state のストアはキーで共有されるので、2 つの定義が同じ行と同じストアを取り合います。

@k8ordo/state の状態の置き場所

保証すること

ここまでの仕組みを合わせると、次のことが成り立ちます。

  • ちらつきません。スクリプトは最初の描画の前に走り、Provider が書くのと同じ行を読みます。ダークのページはダークで読み込まれます。
  • 選んでいなければ既定値に、既定値が 'system' ならシステムに従います。初回訪問時のシステムの値に固定されることはなく、JSON のオブジェクトでない行や、preference'light' でも 'dark' でもない行は「選んでいない」として読みます。
  • サーバーは既定値を描き、hydrate は document に触れません。hydrate する描画は何も書かず、そのあとの描画が、スクリプトがすでに付けたものと同じクラスを書きます。
  • タブ同士が揃います。設定は、@k8ordo/state のほかのローカル状態と同じく storage イベントでタブ間に伝わります。

しないこと

  • サーバーで推測すること。Cookie もヘッダーも読みません。
  • クラス名や付け先を変えること。付けるのは常に <html>dark です。
  • CSS の color-scheme プロパティを設定すること。
  • インラインスクリプトに nonce を付けること。

export される型

値の export は ColorSchemeProvideruseColorSchemecolorSchemeState の 3 つで、型の export は次の 4 つです。宣言どおりに示します。

import type { ReactNode } from 'react';

export type ColorScheme = 'light' | 'dark';

export type ColorSchemePreference = ColorScheme | 'system';

export type ColorSchemeProviderProps = {
  readonly defaultPreference?: ColorSchemePreference;
  readonly children: ReactNode;
};

export type UseColorScheme = {
  readonly scheme: ColorScheme;
  readonly preference: ColorSchemePreference;
  readonly setPreference: (preference: ColorSchemePreference) => void;
};
使いどころ
ColorScheme画面に出るもの。scheme の型です。
ColorSchemePreference訪問者が選べるもの。'system' は何も選ばないことです。preferencedefaultPreference の型です。
ColorSchemeProviderProps<ColorSchemeProvider> の props。Provider を包む自前のコンポーネントに使います。
UseColorSchemeuseColorScheme() の戻り値。それを props で受け取るコンポーネントに使います。

テスト

このパッケージは localStorage、<html> のクラス、matchMedia を使うので、テストはブラウザで走らせます。テストの間では localStorage を消し、<html> から dark を外し、@k8ordo/state の resetStateRegistry() でストアを捨てます。hook は <ColorSchemeProvider> を wrapper にして描きます。

Vitest のブラウザモードと vitest-browser-react で書いた例です。

// src/color-scheme.browser.test.tsx
import {
  ColorSchemeProvider,
  colorSchemeState,
  useColorScheme,
} from '@k8ordo/color-scheme';
import { resetStateRegistry } from '@k8ordo/state';
import type { ReactNode } from 'react';
import { beforeEach, expect, it, vi } from 'vitest';
import { renderHook } from 'vitest-browser-react';

const root = document.documentElement;

const wrapper = ({ children }: { children: ReactNode }) => (
  <ColorSchemeProvider>{children}</ColorSchemeProvider>
);

beforeEach(() => {
  localStorage.clear();
  root.classList.remove('dark');
  resetStateRegistry();
});

it('starts from a stored preference', async () => {
  localStorage.setItem(
    colorSchemeState.storageKey,
    JSON.stringify({ preference: 'dark' }),
  );
  const { result } = await renderHook(() => useColorScheme(), { wrapper });

  expect(result.current.preference).toBe('dark');
  expect(result.current.scheme).toBe('dark');
  expect(root.classList.contains('dark')).toBe(true);
});

it('stores a choice, and stores none for system', async () => {
  const { result } = await renderHook(() => useColorScheme(), { wrapper });

  result.current.setPreference('dark');
  await vi.waitFor(() => {
    expect(result.current.scheme).toBe('dark');
  });
  expect(root.classList.contains('dark')).toBe(true);
  expect(localStorage.getItem(colorSchemeState.storageKey)).toBe(
    '{"preference":"dark"}',
  );

  result.current.setPreference('system');
  await vi.waitFor(() => {
    expect(result.current.preference).toBe('system');
  });
  expect(localStorage.getItem(colorSchemeState.storageKey)).toBe('{}');
});
  • 'system' が解決される先は、テストを走らせるブラウザの prefers-color-scheme です。ダークを好むブラウザで確かめるときは、@vitest/browser-playwrightplaywright()contextOptions: { colorScheme: 'dark' } を渡します。
  • テストのようにクライアントだけで描くと、インラインスクリプトは実行されません。React はブラウザで自分が作ったインラインの <script> を実行せず、開発時にはそのことをコンソールにエラーとして出します。テストで確かめる <html> のクラスは、Provider の effect が書いたものです。
  • resetStateRegistry() の前にコンポーネントをアンマウントします。マウントされたままの hook は古いストアを持ち続けるからです。vitest-browser-react は、各テストの前に前のテストの描画を片付けます。