To ship a dark mode toggle, hand useThemeSetting to <ThemeProvider> for the resolved theme,
build a three-way control for the user's choice, and add a tiny inline script so the first paint
is already correct.
useThemeSetting reads the user's stored preference, falls back to prefers-color-scheme, and
subscribes to OS changes. Its resolved value is 'light' or 'dark' — exactly what
<ThemeProvider active> wants.
App.tsx
import { useThemeSetting, ThemeProvider } from 'usemotif';
import { lightTheme, darkTheme } from './theme';
export function App({ children }: { children: React.ReactNode }) {
const { resolved } = useThemeSetting();
return (
<ThemeProvider themes={[lightTheme, darkTheme]} active={resolved}>
{children}
</ThemeProvider>
);
}
The hook persists to localStorage under motif:theme by default. Pass storageKey to change
the key, or storageKey: null when the host app already owns theme state.
Build the control
A toggle has three states, not two — system, light, dark. Collapsing it to a binary loses
the user's ability to defer to the OS. Drive a segmented control from mode and set.
role="radiogroup" with role="radio" children gives the control arrow-key navigation and a
single-selection announcement out of the box — a segmented control is a radio group dressed
differently.
Kill the first-paint flash
A server-rendered page cannot know the OS preference — Node has no matchMedia. The first HTML
ships with useThemeSetting's defaultResolved (default 'light'). A dark-preferring user then
sees one light frame before hydration corrects it.
Close the gap with a blocking inline script in the document <head>, before any styled markup.
It reads the stored preference and sets data-theme on the wrapper element before first paint.
The script is small enough to inline without measurable cost, and it runs before the browser
paints. <ThemeProvider> then mounts onto the already-correct attribute — the cascade was right
from the first frame.