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.
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.tsxexport 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>
);
}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.
Pitfall
The <Suspense> is not optional. Without one above it, the server render has nowhere to put the fallback, and fails.
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
- Choose “System”.
preferencebecomessystem, andschemeshows the scheme the OS setting gives. - 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”.
- Choose “System” again, then switch your OS’s appearance setting. Only
schemechanges;preferencestayssystem.