@k8ordo/color-scheme

Get Started

パッケージを入れ、ルートレイアウトに Provider を 1 つ置き、切替を 1 つ書き、クラスを読むスタイルを用意するまでの手順です。これで、ダークを選んだ訪問者のページは最初の描画からダークになり、何も選んでいない訪問者は OS の設定に追従します。

持つもの、持たないもの

訪問者のカラースキームは、揃っていなければならない 3 つの値でできています。訪問者が選んだもの、システムが答えるもの、画面に出ているものです。このパッケージはこの 3 つと、それらを結ぶ規則を持ち、受け持つのは <html>dark クラスを付けるところまでです。

  • 訪問者が選んだもの: lightdark、または未選択(既定値に従い、既定ではシステムに追従)。localStorage に保存されます。
  • システムが答えるもの: prefers-color-scheme。ページを開いている間の変化にも追従します。
  • 画面に出ているもの: <html>dark クラス。最初の描画の前に付け、その後も合わせ続けます。

持たないもの

  • 色。dark がどんな色を意味するかはスタイルシートが決めます。@k8ordo/ui のトークンでも、クラスを読む自前の CSS でも構いません。
  • 保存の仕組み。保存先は @k8ordo/state の defineLocalState で、このパッケージはそれを 1 つ宣言し、useAppState を通して読み書きします。localStorage のキーも、行を JSON にする方法も、このパッケージには書かれていません。インラインスクリプトは定義の inlineRead() を使います。
  • サーバーでの推測。Cookie もヘッダーも使いません。サーバーは既定値で描き、最初の描画を正しくするのはインラインスクリプトです。

インストール

@k8ordo/statezod は peer dependency なので、一緒に入れます。設定は @k8ordo/state のローカル状態として保存され、そのスキーマが zod のスキーマだからです。

npm install @k8ordo/color-scheme @k8ordo/state zod
パッケージバージョン用途
@k8ordo/state^0.2.0設定の保存先(localStorage)
react>=19.3.0Provider と hook
zod^4.4.3@k8ordo/state が読む 1 フィールドのスキーマ
typescript>=7.0.2同梱の型定義(任意)
@types/react>=19.3.0React の型(任意)

ルートレイアウトに Provider を置く

<ColorSchemeProvider> はルートレイアウトの <body> の中で、全体を包むように置きます。Provider はクライアントコンポーネントなので、ルートレイアウトは Server Component のままで構いません。このサイトのルートレイアウトも同じ形です。

// src/routes/layout.tsx
import { ColorSchemeProvider } from '@k8ordo/color-scheme';
import type { ReactNode } from 'react';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ColorSchemeProvider>{children}</ColorSchemeProvider>
      </body>
    </html>
  );
}

なぜ <body> の中で全体を包むのか

Provider は、受け取った children より前に、インラインの <script> を描きます。HTML パーサーはそこに着いた時点でスクリプトを実行するので、ページの中身に着く前に <html> にクラスが付きます。Provider より前に置いたものは、クラスが付く前にパースされ、描画されることがあります。このパッケージのために <head> へ置くものはありません。

なぜ <html>suppressHydrationWarning が要るのか

スクリプトは、サーバーが描いていない class="dark"<html> に足します。React は hydrate するとき、document にある <html> の属性を、描画する props と突き合わせ、開発時にはこの class を不一致として報告します。hydrate は属性を書き戻さないので、クラスはそのまま残ります。この差分は意図したものなので、<html>suppressHydrationWarning で報告を止めます。止まるのは <html> 自身の属性の報告だけで、ページの中の不一致はこれまでどおり報告されます。

useColorScheme() で読み、変える

クライアントコンポーネントから useColorScheme() を呼ぶと、Provider が決めた 3 つのメンバーが返ります。hook は Provider を読むだけで、document には触れません。切替とプレビューがずれないのは、決めているのが 1 つの Provider だからです。

メンバー意味
scheme'light' | 'dark'画面に出ているもの。訪問者の設定、Provider の既定値、またはシステムの答えです。
preference'light' | 'dark' | 'system'訪問者が選んだもの。何も保存されていなければ 'system' です。
setPreference(preference: ColorSchemePreference) => void設定を保存します。'system' を渡すと設定を保存せず、再び既定値に従います。

3 つの選択肢をそのまま並べる切替です。preference が今選ばれているものを、scheme が画面に出ている結果を示します。

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

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

const CHOICES: readonly ColorSchemePreference[] = ['system', 'light', 'dark'];

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

  return (
    <fieldset>
      <legend>Colour scheme: {scheme}</legend>
      {CHOICES.map((choice) => (
        <label key={choice}>
          <input
            checked={preference === choice}
            name="color-scheme"
            onChange={() => {
              setPreference(choice);
            }}
            type="radio"
          />
          {choice}
        </label>
      ))}
    </fieldset>
  );
}

トグルは scheme から反転する

2 択のトグルは preference ではなく scheme を見て、反対の値を保存します。何も選んでいない間 preference'system' なので、それを見ても次にどちらへ行くかは決まりません。トグルを押すと選択が保存され、既定値には従わなくなります。戻れるようにしたいなら 'system' の選択肢も用意します。このサイトのヘッダーの切替は、このトグルです。

// 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"
    >
      {scheme === 'dark' ? 'Switch to light' : 'Switch to dark'}
    </button>
  );
}

'system' は「選んでいない」こと

setPreference('system') は設定を保存せず、preference の無い行({})を書きます。そのあと適用されるのは Provider の defaultPreference です。preference は、一度も選んでいない訪問者でも、選んだあと戻した訪問者でも 'system' です。defaultPreference'dark' にしていても同じです。

hydrate される前の値

サーバーは localStorage を読めないので、サーバーが描く scheme は既定値です(defaultPreference'system' なら 'light')。scheme から選んだアイコンやラベルは、hydrate されるまでその値を表示します。最初の描画から正しくなければならないものは、dark: のようにクラスを読む CSS で出し分けます。 サーバーが描くものの詳細

Provider の外では例外を投げる

useColorScheme() は、上に <ColorSchemeProvider> が無いと次のエラーを投げます。黙って既定値を返すことはありません。

useColorScheme needs <ColorSchemeProvider> above it — put one in the root layout, inside <body>

既定値を変える

defaultPreference は、訪問者が何も選んでいない間に適用される値です。既定は 'system' で、prefers-color-scheme に従います。'light''dark' を渡すと、訪問者が選ぶまではその値が適用されます。

// src/routes/layout.tsx
import { ColorSchemeProvider } from '@k8ordo/color-scheme';
import type { ReactNode } from 'react';

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <body>
        <ColorSchemeProvider defaultPreference="dark">
          {children}
        </ColorSchemeProvider>
      </body>
    </html>
  );
}

既定値は保存されません。あとで既定値を変えると、選んでいない訪問者はみな新しい既定値に移り、選んだ訪問者は自分の選択のままです。インラインスクリプトにも同じ既定値が埋め込まれるので、最初の描画も新しい既定値で始まります。

クラスでスタイルを当てる

このパッケージが出力するのはクラス 1 つです。そのクラスの下で何が変わるかは CSS が決めます。

@k8ordo/ui と使う

@k8ordo/ui のセマンティックトークンは .dark の下で切り替わります。styles.css でも tailwind.css でも同じなので、コンポーネントも bg-bg-base のようなユーティリティも、追加の設定なしでクラスに従います。tailwind.cssdark: バリアントもクラスを読むように宣言しているので、自前のマークアップでも dark: がそのまま使えます。

/* src/styles/globals.css */
@import '@k8ordo/ui/tailwind.css';
// src/components/logo.tsx
export function Logo() {
  return (
    <div className="bg-bg-base text-fg-base rounded-md p-4">
      <img alt="k8ordo" className="dark:invert" src="/logo.svg" />
    </div>
  );
}

Tailwind CSS だけで使う

Tailwind CSS 4 の dark: バリアントは、既定では prefers-color-scheme を読みます。そのままでは OS の設定に従い、訪問者の選択を無視します。クラスを読むように宣言し直します。@k8ordo/ui の tailwind.css がしている宣言と同じものです。

/* src/styles/globals.css */
@import 'tailwindcss';

@custom-variant dark (&:where(.dark, .dark *));

素の CSS で使う

色をクラスに結びつけます。このパッケージも @k8ordo/ui のトークンも CSS の color-scheme プロパティは設定しないので、フォーム部品やスクロールバーのようなブラウザ自身の描画も合わせたいなら、色と一緒に宣言します。

/* src/styles/globals.css */
:root {
  color-scheme: light;
  --page-bg: #ffffff;
  --page-fg: #1f1f1f;
}

:root.dark {
  color-scheme: dark;
  --page-bg: #1f1f1f;
  --page-fg: #f5f5f5;
}

body {
  background: var(--page-bg);
  color: var(--page-fg);
}

次に読む