@k8ordo/color-scheme

Get Started

Install the package, put one provider in the root layout, write one switcher, and style with the class it sets. With that, a visitor who chose dark gets a page that is dark from the first paint, and a visitor who chose nothing follows the OS setting.

What it owns, and what it does not

A visitor’s colour scheme is three values that have to agree: what they chose, what the system says, and what is on screen. This package owns all three and the rule between them, and stops at the dark class on <html>.

  • What the visitor chose: light, dark, or nothing, which follows the default (the system, unless told otherwise). Kept in localStorage.
  • What the system says: prefers-color-scheme, followed for as long as the page is open.
  • What is on screen: the dark class on <html>, put there before the first paint and kept in step afterwards.

What it does not own

  • The colours. What dark looks like is the stylesheet’s business — @k8ordo/ui’s tokens, or any CSS of your own that reads the class.
  • The storage. The row is an @k8ordo/state defineLocalState; this package declares one and reads and writes it through useAppState. Neither the localStorage key nor how the row is serialized is written anywhere in this package: the inline script uses the definition’s inlineRead().
  • A guess on the server. No cookie, no header: the server renders the default, and the inline script is what makes the first paint right.

Installation

@k8ordo/state and zod are peer dependencies, so install them alongside: the preference is stored as an @k8ordo/state local state, and its schema is a zod schema.

npm install @k8ordo/color-scheme @k8ordo/state zod
PackageVersionNeeded for
@k8ordo/state^0.2.0where the preference is kept (localStorage)
react>=19.3.0the provider and the hook
zod^4.4.3the one-field schema @k8ordo/state reads
typescript>=7.0.2the shipped type declarations (optional)
@types/react>=19.3.0React’s types (optional)

Put the provider in the root layout

<ColorSchemeProvider> goes in the root layout, inside <body>, around everything. The provider is a client component, so the root layout stays a Server Component. This site’s root layout has the same shape.

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

Why inside <body>, around everything

The provider renders an inline <script> before its children. The HTML parser runs it as soon as it reaches it, so the class is on <html> before the parser reaches anything the page renders. Anything placed before the provider is parsed — and may be painted — before the class is on. This package needs nothing in <head>.

Why <html> needs suppressHydrationWarning

The script adds class="dark" to <html>, which the server did not render. When React hydrates, it compares the attributes on <html> in the document with the props it renders, and in development it reports that class as a mismatch. Hydration does not write attributes back, so the class stays. The difference is intended, so suppressHydrationWarning on <html> silences the report. It covers <html>’s own attributes only: mismatches inside the page are still reported.

Read and change it with useColorScheme()

Call useColorScheme() from a client component and it returns three members, decided by the provider. The hook only reads the provider and never touches the document; a switcher and a preview cannot disagree, because one provider decides for both.

MemberTypeWhat it is
scheme'light' | 'dark'What is on screen: the preference, the provider’s default, or the system’s answer.
preference'light' | 'dark' | 'system'What the visitor chose; 'system' when nothing is stored.
setPreference(preference: ColorSchemePreference) => voidStores a preference; 'system' stores no preference and follows the default again.

A switcher that offers all three choices. preference marks the one chosen; scheme shows the result on screen.

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

A toggle flips scheme

A two-way toggle reads scheme, not preference, and stores the other side. While nothing is chosen preference is 'system', which does not say which way to go. Pressing the toggle stores a choice, and the default no longer applies; offer 'system' as well if visitors should be able to go back. The switcher in this site’s header is such a toggle.

// 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' is the absence of a choice

setPreference('system') stores no preference: the row is written without preference ({}), and the provider’s defaultPreference applies again. preference reads 'system' for a visitor who never chose and for one who chose and went back, even when defaultPreference is 'dark'.

Before hydration

The server cannot read localStorage, so the scheme it renders is the default ('light' when defaultPreference is 'system'). An icon or a label chosen from scheme shows that value until hydration. Anything that must be right from the first paint is switched by CSS that reads the class, such as dark:. More on what the server renders

Outside the provider it throws

useColorScheme() throws the following error when there is no <ColorSchemeProvider> above it. It never falls back to a default silently.

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

Change the default

defaultPreference is what applies while the visitor has chosen nothing. It is 'system' unless told otherwise, which follows prefers-color-scheme; pass 'light' or 'dark' and that value applies until the visitor chooses.

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

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

Style with the class

What this package produces is one class. What changes under it is decided by CSS.

With @k8ordo/ui

@k8ordo/ui’s semantic tokens switch under .dark, in styles.css and tailwind.css alike, so the components and utilities such as bg-bg-base follow the class with nothing else to set up. tailwind.css also declares the dark: variant to read the class, so dark: works in your own markup as it is.

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

With Tailwind CSS alone

Tailwind CSS 4’s dark: variant reads prefers-color-scheme by default, so on its own it follows the OS and ignores the visitor’s choice. Redeclare it to read the class — the same declaration @k8ordo/ui’s tailwind.css makes.

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

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

With plain CSS

Tie the colours to the class. Neither this package nor @k8ordo/ui’s tokens set the CSS color-scheme property, so declare it next to the colours if the browser’s own rendering, such as form controls and scrollbars, should follow too.

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

Next steps