@k8ordo/color-scheme

How it works

What the provider decides, from what, and when: the one rule, the inline script that applies it before the first paint, how it stays in step afterwards, the row the preference is stored in, what all of that guarantees, and how to test it.

One rule

What the visitor chose wins; then the provider’s defaultPreference; and when that is 'system', the system is asked. What reaches the screen is the scheme this rule resolves to, and nothing else.

Stored preferencedefaultPreference(prefers-color-scheme: dark)scheme
'dark'anyany'dark'
'light'anyany'light'
none'dark'any'dark'
none'light'any'light'
none'system'matches'dark'
none'system'does not match'light'

A row that is not a JSON object, or whose preference is neither 'light' nor 'dark' ({"preference":"sepia"} or {}, say), reads as none, by the inline script and by the store alike. Neither reads any other field in the row.

“None” is not a snapshot of what the system said on the first visit: with a 'system' default, a visitor who never chose keeps following the OS when its setting changes later.

On this page

The inputs this page reads right now, and the result. This site’s root layout passes no defaultPreference, so it is 'system'. Press the switcher in the header, change the OS setting, or change the setting in another tab of this site, and the rows it affects change in place. The localStorage.getItem row is null if nothing was ever chosen, and '{}' after going back to 'system'. The choice that goes back to 'system' is in the demo on the @k8ordo/color-scheme landing page.

Inputs

matchMedia('(prefers-color-scheme: dark)').matches
read in the browser
localStorage.getItem('k8ordo-state:color-scheme')
read in the browser
useAppState(colorSchemeState)
read in the browser

Result

useColorScheme().preference
read in the browser
useColorScheme().scheme
read in the browser
document.documentElement.classList.contains('dark')
read in the browser

Before the first paint

React runs only once its JavaScript has loaded, and by then the browser may already have painted. If an effect were the only thing putting the class on, the page would paint with the default and then flip, a flash of light for a visitor who chose dark. So the provider states the same rule a second time, as an inline script, and renders it as its first child.

What the script reads

  • The k8ordo-state:color-scheme row, through colorSchemeState.inlineRead(): the same row the store writes.
  • The row’s preference if it is exactly 'light' or 'dark'; otherwise the defaultPreference given to the provider, which is written into the script’s text. The schema does not run here, so the value is checked by hand.
  • matchMedia('(prefers-color-scheme: dark)'), only when the value picked that way is 'system'.
  • If the result is dark, document.documentElement.classList.add('dark'). It never removes the class, and does nothing after that.

The script written into the HTML when defaultPreference is 'system', formatted for reading.

(() => {
  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');
})();

On hydration React adopts the <script> element in place and does not run it again.

Being an inline script, it does not run under a Content Security Policy that forbids inline scripts, and the provider takes no nonce.

What the server renders

A server has no localStorage and no system to ask. The provider renders as if nothing were stored and the system were light: preference is 'system', and scheme is the defaultPreference, or 'light' when that is 'system'. <html> carries no dark class in the HTML; the script adds it. There is no cookie or header to guess earlier.

CSS that reads the class is right from the first paint. Markup chosen from scheme, such as an icon or a label, shows the server’s value until hydration. Anything that must be right from the start renders both and lets CSS hide one.

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

This example relies on a dark: variant that reads the class, as @k8ordo/ui’s tailwind.css declares. Tailwind CSS 4’s default dark: reads prefers-color-scheme instead. Get Started: Style with the class

What CSS cannot switch can be left out of the server HTML with React’s use(browser()) (browser from react-dom) under a <Suspense>: the server writes the fallback instead of a guess, and the content is rendered in the browser. @k8ordo/static: Components that need a browser

Staying in step

After hydration the provider is the only thing that writes the class. Whenever one of its inputs changes it resolves the rule again, and when scheme changes an effect toggles dark on <html>.

  • The visitor chooses: setPreference updates the store. The new value is in the very next render, and the write to localStorage follows right after, batched.
  • The system changes: the provider subscribes to change on matchMedia('(prefers-color-scheme: dark)'). With nothing chosen and a 'system' default, the page follows an OS change in either direction while it is open.
  • Another tab changes it: @k8ordo/state’s local state listens for storage events on this key. A choice made in another tab, or localStorage cleared there, reaches this tab’s provider.
  • Hydration: the render that hydrates reads the server’s guesses and writes nothing; the render straight after reads the store, and that one writes. The class the script put on is never taken off along the way.

Do not write the row by hand in the same tab

A storage event never reaches the tab that wrote. Writing localStorage.setItem('k8ordo-state:color-scheme', …) by hand in the same tab goes unnoticed by the provider until a reload. Change it through setPreference.

One provider

Put exactly one provider in the root layout. Each provider renders its own script and writes the class, so two providers with different defaultPreference values would disagree. useColorScheme() reads the nearest one.

Where it is stored: colorSchemeState

The preference is stored as an ordinary @k8ordo/state local state. Its definition is exported as colorSchemeState, and this is all of it.

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'])) }),
);

The key is color-scheme, so the localStorage key is k8ordo-state:color-scheme (colorSchemeState.storageKey). preference is optional, and its absence is what “nothing chosen” means.

WhenThe stored row
Never choseno row (getItem returns null)
setPreference('dark'){"preference":"dark"}
setPreference('light'){"preference":"light"}
setPreference('system'){}

Going back to 'system' does not remove the row: {} remains, with no preference in it. Anything that reads preference treats it the same as no row.

Reading it elsewhere

To read the row without the hook, call useAppState(colorSchemeState) from any client component. @k8ordo/state has no provider and keeps one store per key, so it reads what <ColorSchemeProvider> reads.

// 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>;
}

What comes back is the stored preference ('light' | 'dark' | undefined), not the resolved scheme; for what is on screen, use useColorScheme(). On the server and in the hydration render it is the nothing-stored value, undefined.

An inline script of your own that needs the row before the first paint has colorSchemeState.inlineRead(): it returns a JavaScript expression that evaluates to the stored object, or to null when there is none it can read. The schema does not run there, so check each field you use. @k8ordo/state: Reading before hydration

Do not define another defineLocalState('color-scheme', …) in the application: @k8ordo/state shares stores by key, so the two definitions would fight over one row and one store.

Where @k8ordo/state keeps state

What it guarantees

Put together, the mechanics above guarantee the following.

  • No flash. The script runs before the first paint and reads the same row the provider writes; a dark page loads dark.
  • Nothing chosen follows the default, and a 'system' default follows the system. A visitor is never pinned to what the system said on their first visit, and a row that is not a JSON object, or whose preference is neither 'light' nor 'dark', reads as nothing chosen.
  • The server renders the default, and hydration does not touch the document. The render that hydrates writes nothing; the render after it writes the class the script already put there.
  • Tabs agree. The preference travels between tabs through the storage event, as any @k8ordo/state local state does.

What it does not do

  • Guess on the server: it reads no cookie and no header.
  • Change the class or where it goes: it is always dark, on <html>.
  • Set the CSS color-scheme property.
  • Put a nonce on the inline script.

Exported types

The value exports are ColorSchemeProvider, useColorScheme and colorSchemeState; the type exports are these four, shown as declared.

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;
};
TypeWhat it is for
ColorSchemeWhat can be on screen; the type of scheme.
ColorSchemePreferenceWhat a visitor can choose, where 'system' is choosing nothing; the type of preference and defaultPreference.
ColorSchemeProviderPropsThe props of <ColorSchemeProvider>, for a component of your own that wraps it.
UseColorSchemeWhat useColorScheme() returns, for a component that receives it as a prop.

Testing

The package uses localStorage, the class on <html> and matchMedia, so test in a browser. Between tests, clear localStorage, remove dark from <html>, and drop the stores with @k8ordo/state’s resetStateRegistry(); render the hook with <ColorSchemeProvider> as the wrapper.

An example with Vitest’s browser mode and 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('{}');
});
  • What 'system' resolves to is the test browser’s prefers-color-scheme. To test against a browser that prefers dark, pass contextOptions: { colorScheme: 'dark' } to playwright() from @vitest/browser-playwright.
  • A client-only render, as in a test, does not run the inline script: React never executes an inline <script> it creates in the browser, and in development it logs an error saying so. The class a test sees on <html> is the one the provider’s effect wrote.
  • Unmount before resetStateRegistry(), because a hook that stays mounted keeps its old store. vitest-browser-react cleans up the previous test’s render before each test.