Skip to content
General

ThemeToggle

A three-way colour-scheme control: light, dark, and follow the system. One segmented group of icon buttons, each carrying aria-pressed. It owns no theme state — pass the mode in, take the change back out.

Import

import { ThemeToggle } from "@mikenotthepope/substrateui/organisms"

It is three modes, not two

Worth saying plainly, because the control it is most often confused with is a Sun and a Moon swapped by dark:hidden and dark:block— one button, two states, no JavaScript. That one is charming and it cannot express "follow the system", which is the mode most people are actually in.

Here each mode is its own button carrying aria-pressed, so which one is on is announced rather than only drawn. Each is icon-only and each is labelled.

It owns no theme state

That is not a shortcut. It is what keeps this package free of a theming library. An earlier version of this component read useTheme from next-themes directly — and next-themes is a devDependency here rather than a peer, so the bundler does not leave it alone: publishing the component like that put a private second copy of the package inside dist/organisms.js. Its React context is one no consumer's own <ThemeProvider> can reach, so useTheme falls back to { setTheme: () => {}, themes: [] }: three unpressed buttons that do nothing, with no error anywhere.

Toaster was cut loose from the same package for a near-identical reason. audit:boundary now fails the library build if any published entry bundles next or next-themes again.

Wiring it to next-themes

Five lines, in your application, where next-themes is a real dependency:

"use client"

import { useTheme } from "next-themes"
import { ThemeToggle, type ThemeMode } from "@mikenotthepope/substrateui/organisms"

export function ModeToggle() {
  const { theme, setTheme } = useTheme()
  return <ThemeToggle value={theme as ThemeMode} onValueChange={setTheme} />
}

The placeholder

With no value it renders an aria-hidden box of the same height instead of the buttons. The mode usually comes from localStorage, which the server cannot read, so the first client render has to agree with the server render — and three buttons whose pressed state was guessed do not. This is the mount wait, moved to where it is cheap: a caller who knows the mode on the server never pays for it.

Where it belongs on this site

In the header's preferences popover and nowhere else — see the site chrome rules in CONTRIBUTING.md. That is a rule about this documentation site, not about your application.

API Reference

PropTypeDefaultDescription
value"light" | "dark" | "system"—The mode that is currently on. `undefined` renders a same-height placeholder instead of the buttons.
onValueChange(mode: ThemeMode) => void—Called with the mode the user picked.
labelsThemeToggleLabels—Translations for the three accessible names.
classNamestring—Merged onto the group.