Recipes

Dark mode toggle

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.

Updated 3 days agoEdit on GitHubWeb & native

Wire the provider

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.

ThemeToggle.tsx
import { useThemeSetting, HStack, Pressable, Text } from 'usemotif';
import type { ThemeMode } from 'usemotif';
 
const OPTIONS: readonly { value: ThemeMode; label: string }[] = [
{ value: 'system', label: 'System' },
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
];
 
export function ThemeToggle() {
const { mode, set } = useThemeSetting();
return (
  <HStack
    gap="$1"
    p="$1"
    bg="$colors.surface.muted"
    borderRadius="$radii.full"
    role="radiogroup"
    aria-label="Colour theme"
  >
    {OPTIONS.map((opt) => (
      <Pressable
        key={opt.value}
        role="radio"
        aria-checked={mode === opt.value}
        onPress={() => set(opt.value)}
        px="$3"
        py="$1"
        borderRadius="$radii.full"
        bg={mode === opt.value ? '$colors.surface.base' : 'transparent'}
      >
        <Text fontSize="$sm">{opt.label}</Text>
      </Pressable>
    ))}
  </HStack>
);
}

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.

index.html
<script>
(function () {
  try {
    var stored = localStorage.getItem('motif:theme');
    var systemDark = matchMedia('(prefers-color-scheme: dark)').matches;
    var resolved = stored === 'light' || stored === 'dark'
      ? stored
      : systemDark ? 'dark' : 'light';
    document.documentElement.dataset.theme = resolved;
  } catch (e) {}
})();
</script>

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.