@k8ordo/color-scheme

Build a switch

A switch is built from what useColorScheme() returns. This page builds a toggle between light and dark and a three-way choice that includes the system, then covers changing the default and keeping the server’s guess off screen before hydration.

On this page

What useColorScheme() returns

Call useColorScheme() in a Client Component and it returns three things, decided by the provider in the root layout.

  • scheme: what is on screen, 'light' or 'dark'. It comes from the visitor’s choice, the provider’s default, or the OS setting.
  • preference: what the visitor chose, 'light', 'dark' or 'system'. It is 'system' while nothing is chosen.
  • setPreference: stores a choice. Passing 'system' clears the stored choice, so the provider’s default applies again.

The hook only reads what the provider decided; it never touches the class on <html> or localStorage. However many switches a page has, one provider decides for all of them, so they never disagree.

Build a two-way toggle

To flip between light and dark, read scheme and store the other one.

scheme-toggle.tsx
'use client';

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

export function SchemeToggle() {
  const { scheme, setPreference } = useColorScheme();
  const next = scheme === 'dark' ? 'light' : 'dark';

  return (
    <button
      onClick={() => {
        setPreference(next);
      }}
      type="button"
    >
      Switch to {next}
    </button>
  );
}

It reads scheme, not preference, because while nothing is chosen preference is 'system', which does not say which way to go.

One press stores a choice, and from then on changing the OS setting no longer changes the scheme. To let visitors go back to following the OS, offer the three-way choice below. The switch in this site’s header is this toggle.

Offer the system as a third choice

To let the visitor choose to follow the OS as well, use preference as the value of the choice.

scheme-select.tsx
'use client';

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

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

  return (
    <select
      onChange={(event) => {
        setPreference(
          event.currentTarget.value as ColorSchemePreference,
        );
      }}
      value={preference}
    >
      <option value="system">System ({scheme})</option>
      <option value="light">Light</option>
      <option value="dark">Dark</option>
    </select>
  );
}

setPreference('system') clears the stored choice. So preference reads 'system' both for a visitor who never chose and for one who chose and went back.

Showing scheme beside the system option tells the visitor which scheme the OS setting gives right now.

Change the default

What a visitor who chose nothing gets is the provider’s defaultPreference. Left out, it is 'system', which follows the OS setting.

layout.tsx
<ColorSchemeProvider defaultPreference="dark">
  {children}
</ColorSchemeProvider>

The default is never stored. Change it later, and every visitor who has not chosen moves to the new default, while those who chose keep their choice. The inline script carries the same default, so the first paint starts from it too.

With a default other than 'system', a “System” option no longer says what it does: as a preference, 'system' means “nothing chosen”, not “follow the OS”. Label it something like “Default” instead.

Warning

Pitfall

If a CSP allows the inline script by hash, give colorSchemeScriptHash() the same default. The default is written into the script, so changing it changes the hash.

Keep the server’s guess off screen

A server cannot read localStorage, so the provider renders the default there; with a 'system' default, the scheme it renders is 'light'. An icon or a label chosen from scheme therefore shows the server’s guess until hydration.

The page’s colours are right from the first paint, because they follow the dark class the inline script put on. Only markup chosen in React’s render is off, and there are two ways to fix it.

Render both and let CSS hide one

Where CSS can do the switching, render both states and hide one with the dark: variant. The class is on before the first paint, so the right one shows without waiting for hydration.

scheme-toggle.tsx
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>
  );
}
Information

Note

This dark: is a variant declared to read the class on <html>. @k8ordo/ui’s tailwind.css already declares it, but Tailwind CSS’s default dark: reads the OS setting. Declaring it with Tailwind CSS alone

Wait for the browser

Some things are awkward to switch with CSS, such as swapping an icon. For those, call use(browser()) in the component that renders the markup, and put it inside a <Suspense>; browser comes from react-dom. The server writes the fallback into the HTML instead of a guess, and the content is rendered in the browser.

scheme-toggle.tsx
'use client';

import { useColorScheme } from '@k8ordo/color-scheme';
import { Suspense, use } from 'react';
import { browser } from 'react-dom';

import { MoonIcon, SunIcon } from './icons';

function SchemeIcon() {
  use(browser('the stored preference is in localStorage'));
  const { scheme } = useColorScheme();
  return scheme === 'dark' ? <MoonIcon /> : <SunIcon />;
}

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

  return (
    <button
      aria-label="Toggle colour scheme"
      onClick={() => {
        setPreference(scheme === 'dark' ? 'light' : 'dark');
      }}
      type="button"
    >
      <Suspense fallback={<span className="size-5" />}>
        <SchemeIcon />
      </Suspense>
    </button>
  );
}

Give the fallback an empty element the size of the icon, so the button keeps its size when the icon arrives. The button itself is in the server’s HTML, so it works as soon as hydration finishes.

Warning

Pitfall

The <Suspense> is not optional. Without one above it, the server render has nowhere to put the fallback, and fails.

Playground

Try it on this site

A toggle and a three-way choice, both wired to this site’s provider. Either one changes the colour scheme of the whole site.

Try it

  1. Choose “System”. preference becomes system, and scheme shows the scheme the OS setting gives.
  2. Press “Switch to dark” or “Switch to light”. The opposite of what was on screen is stored, and the three-way choice moves to “Dark” or “Light”.
  3. Choose “System” again, then switch your OS’s appearance setting. Only scheme changes; preference stays system.
k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2