Get Started
パッケージを入れ、ルートレイアウトに Provider を 1 つ置き、切替を 1 つ書き、クラスを読むスタイルを用意するまでの手順です。これで、ダークを選んだ訪問者のページは最初の描画からダークになり、何も選んでいない訪問者は OS の設定に追従します。
持つもの、持たないもの
訪問者のカラースキームは、揃っていなければならない 3 つの値でできています。訪問者が選んだもの、システムが答えるもの、画面に出ているものです。このパッケージはこの 3 つと、それらを結ぶ規則を持ち、受け持つのは <html> に dark クラスを付けるところまでです。
- 訪問者が選んだもの:
light、dark、または未選択(既定値に従い、既定ではシステムに追従)。localStorage に保存されます。 - システムが答えるもの:
prefers-color-scheme。ページを開いている間の変化にも追従します。 - 画面に出ているもの:
<html>のdarkクラス。最初の描画の前に付け、その後も合わせ続けます。
持たないもの
- 色。
darkがどんな色を意味するかはスタイルシートが決めます。@k8ordo/ui のトークンでも、クラスを読む自前の CSS でも構いません。 - 保存の仕組み。保存先は @k8ordo/state の
defineLocalStateで、このパッケージはそれを 1 つ宣言し、useAppStateを通して読み書きします。localStorage のキーも、行を JSON にする方法も、このパッケージには書かれていません。インラインスクリプトは定義のinlineRead()を使います。 - サーバーでの推測。Cookie もヘッダーも使いません。サーバーは既定値で描き、最初の描画を正しくするのはインラインスクリプトです。
インストール
@k8ordo/state と zod は peer dependency なので、一緒に入れます。設定は @k8ordo/state のローカル状態として保存され、そのスキーマが zod のスキーマだからです。
npm install @k8ordo/color-scheme @k8ordo/state zod| パッケージ | バージョン | 用途 |
|---|---|---|
@k8ordo/state | ^0.2.0 | 設定の保存先(localStorage) |
react | >=19.3.0 | Provider と hook |
zod | ^4.4.3 | @k8ordo/state が読む 1 フィールドのスキーマ |
typescript | >=7.0.2 | 同梱の型定義(任意) |
@types/react | >=19.3.0 | React の型(任意) |
ルートレイアウトに 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.css は dark: バリアントもクラスを読むように宣言しているので、自前のマークアップでも 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);
}