System, Light or Dark: the reader's choice, applied at once and kept by your app.

No flash: your part and ours

System needs nothing. With no data-theme on <html>, the tokens follow the reader's system setting in CSS, and color-scheme follows it too, so a page whose choice is System paints right with no script and no cookie.

The parts read nothing. ThemeSwitch and ThemeMenu draw the value you give them and call onChange. They never read matchMedia, storage, document.cookie or data-theme, and they write nothing: no storage, no cookie and no script, which a nonce-locked content security policy forbids anyway. Pass null while you do not know the choice yet, and no choice shows.

Your part: read the cookie on the server. Your root layout reads the theme cookie and writes data-theme="light" or data-theme="dark" on <html>, or nothing for System. The first paint is right, with no script, no flash and no hydration warning.

// app/layout.tsx
import { cookies } from 'next/headers';
import { ThemeProvider } from './theme-provider';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const saved = (await cookies()).get('theme')?.value;
  const theme = saved === 'light' || saved === 'dark' ? saved : 'system';
  return (
    <html lang="en" data-theme={theme === 'system' ? undefined : theme}>
      <body>
        <ThemeProvider initial={theme}>{children}</ThemeProvider>
      </body>
    </html>
  );
}

Your part: apply it at once and keep it. In onChange, set the attribute on <html> in the same frame, or remove it for System, and write the cookie for the next request, through a server action or document.cookie.

// app/theme-actions.ts
'use server';

import { cookies } from 'next/headers';
import type { ThemeChoice } from '@snapshot-ui/react';

export async function saveTheme(choice: ThemeChoice) {
  const jar = await cookies();
  if (choice === 'system') jar.delete('theme');
  else jar.set('theme', choice, { path: '/', maxAge: 60 * 60 * 24 * 365, sameSite: 'lax' });
}

// app/theme-provider.tsx
'use client';

import { createContext, use, useState } from 'react';
import type { ThemeChoice } from '@snapshot-ui/react';
import { saveTheme } from './theme-actions';

const ThemeContext = createContext<[ThemeChoice, (choice: ThemeChoice) => void] | null>(null);

export function ThemeProvider({ initial, children }: { initial: ThemeChoice; children: React.ReactNode }) {
  const [theme, setTheme] = useState(initial);
  const choose = (choice: ThemeChoice) => {
    if (choice === 'system') delete document.documentElement.dataset.theme;
    else document.documentElement.dataset.theme = choice;
    setTheme(choice);
    void saveTheme(choice);
  };
  return <ThemeContext value={[theme, choose]}>{children}</ThemeContext>;
}

export const useTheme = () => use(ThemeContext)!;

Reading a cookie in the root layout renders every route per request. A site that serves static pages, as these docs do, cannot read it on the server; it uses a pre-paint script of its own instead, as next-themes does, with suppressHydrationWarning on <html>, and passes null until the browser knows the choice. That script is the app's, never the library's. Other tabs keep their theme until they reload, unless your app listens for a change of its own.

In Settings

Appearance is a card or a section of its own, outside every form and every SaveBar: a choice applies at once and is never part of a save, so it shows no Changed chip. The segmented control fits three short choices; System comes first, since it is the default.

Example

Appearance

ThemeChanged

Applied at once, on this device.

A card in the theme you chose.

Follows the page theme and corners
'use client';

import {
  Card,
  CardBody,
  CardHeader,
  CardTitle,
  Text,
  ThemeSwitch,
  type ThemeChoice,
} from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

/**
 * Appearance in a card of its own, outside every form: a choice applies at once. In an app, `onChange` sets
 * `data-theme` on `<html>` and writes the cookie; here the choice themes the preview under the switch, so the
 * docs page keeps its own theme.
 */
export default function ThemeSwitchBasic(): ReactElement {
  const [theme, setTheme] = useState<ThemeChoice>('system');
  return (
    <div style={{ width: 'min(32rem, 100%)', display: 'grid', gap: 'var(--spacing-peer)' }}>
      <Card>
        <CardHeader>
          <CardTitle level={3}>Appearance</CardTitle>
        </CardHeader>
        <CardBody>
          <ThemeSwitch label="Theme" value={theme} onChange={setTheme} />
          <Text>Applied at once, on this device.</Text>
        </CardBody>
      </Card>
      {/* System is no attribute: the preview follows the reader's system, as the page would. */}
      <div data-theme={theme === 'system' ? undefined : theme}>
        <Card>
          <CardBody>
            <Text>A card in the theme you chose.</Text>
          </CardBody>
        </Card>
      </div>
    </div>
  );
}

With look="radio", the choices are rows of a radio group, for a page with room.

Example

ThemeChanged
Follows the page theme and corners
'use client';

import { ThemeSwitch, type ThemeChoice } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

/** The radio look, a row per choice, for a page with room. */
export default function ThemeSwitchRadio(): ReactElement {
  const [theme, setTheme] = useState<ThemeChoice>('dark');
  return (
    <div style={{ width: 'min(20rem, 100%)' }}>
      <ThemeSwitch look="radio" label="Theme" value={theme} onChange={setTheme} />
    </div>
  );
}

In a menu

ThemeMenu goes among a Menu's items, as AccountMenu holds them: a Theme item showing the current choice and opening a submenu of the three, or with display="section" the three under a Theme heading in the menu itself. A choice closes every level and gives focus back to the menu's trigger.

Example

Theme: system

Follows the page theme and corners
'use client';

import {
  Button,
  Menu,
  MenuItem,
  MenuSeparator,
  MenuTrigger,
  Text,
  ThemeMenu,
  type ThemeChoice,
} from '@snapshot-ui/react';
import { LogOut, Settings } from 'lucide-react';
import { useState, type ReactElement } from 'react';

/** The theme in a menu: a Theme item opening a submenu, and the three in a section of the menu itself. */
export default function ThemeSwitchMenu(): ReactElement {
  const [theme, setTheme] = useState<ThemeChoice>('system');
  return (
    <div style={{ display: 'grid', gap: 16, justifyItems: 'center' }}>
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, justifyContent: 'center' }}>
        <MenuTrigger>
          <Button variant="secondary">Submenu</Button>
          <Menu>
            <MenuItem id="settings" icon={<Settings />}>
              Settings
            </MenuItem>
            <ThemeMenu value={theme} onChange={setTheme} />
            <MenuSeparator />
            <MenuItem id="sign-out" icon={<LogOut />}>
              Sign out
            </MenuItem>
          </Menu>
        </MenuTrigger>
        <MenuTrigger>
          <Button variant="secondary">Section</Button>
          <Menu>
            <MenuItem id="settings" icon={<Settings />}>
              Settings
            </MenuItem>
            <MenuSeparator />
            <ThemeMenu value={theme} onChange={setTheme} display="section" />
            <MenuSeparator />
            <MenuItem id="sign-out" icon={<LogOut />}>
              Sign out
            </MenuItem>
          </Menu>
        </MenuTrigger>
      </div>
      <Text>Theme: {theme}</Text>
    </div>
  );
}

Usage

import { ThemeMenu, ThemeSwitch } from '@snapshot-ui/react';

const [theme, choose] = useTheme();

<ThemeSwitch label="Theme" value={theme} onChange={choose} />;

<AccountMenu name="Ada Lovelace" detail="ada@example.com">
  <MenuItem id="settings" href="/settings">
    Settings
  </MenuItem>
  <ThemeMenu value={theme} onChange={choose} />
</AccountMenu>;

ThemeSwitch takes a segmented control's props but its value: size, label, description, aria-label and the rest go on the control. It is named "Theme" unless you name it.

Props

className
string | ((values: RadioGroupRenderProps & { defaultClassName: string | undefined; }) => string)

A class name, or a function of the control's state, merged after the library's own classes.

hasIcons
boolean

Icons before each choice (Monitor, Sun, Moon), hidden from assistive technology.

Default: true

label
ReactNode

The control's name, shown above it with the Changed chip. Required unless the control has an aria-label or an aria-labelledby.

labels
Partial<ThemeLabels>

The words: the control's name and the three choices.

look
"radio" | "segmented"

segmented is a SegmentedControl; radio a RadioGroup of rows.

Default: 'segmented'

onChange
(choice: ThemeChoice) => void

Called with the reader's choice. Apply it at once and keep it: the part writes nothing itself.

size
SegmentedControlSize

md is a 48px track of 40px options. sm is a 40px track whose 32px options are still hit at 40px, for a toolbar or a header.

Default: 'md'

value
ThemeChoice | null

The choice, from the app's cookie; null shows none, for a value the browser learns after hydration.

display
"section" | "submenu"

submenu is a Theme item opening the three; section puts them in the menu under a Theme heading.

Default: 'submenu'

labels
Partial<ThemeLabels>

The words: the item's or the heading's name and the three choices.

onChange
(choice: ThemeChoice) => void

Called with the reader's choice, after which every level of the menu closes.

value
ThemeChoice | null

The choice, from the app's cookie; null checks none.

dark
string

Default: 'Dark'

label
string

The control's or the submenu's name.

Default: 'Theme'

light
string

Default: 'Light'

system
string

Default: 'System'

Accessibility

ThemeSwitch is a radio group named "Theme", each choice a radio named by its word, its icon hidden. The arrows move to the next choice and apply it, as in any radio group: a theme is cheap to undo. ThemeMenu's choices are menuitemradio items with the chosen one checked, in a submenu opened by a Theme item with aria-haspopup and aria-expanded, or in a section named by its heading. The submenu's open and close arrows follow the locale, so a Persian page sets I18nProvider with its locale and the arrows swap.

TabInto the group, on the chosen choice.
LeftRightUpDownMove to the next or previous choice and apply it.
EnterSpaceRightOn Theme in a menu, open the submenu with focus on the chosen choice. Left in right to left.
DownUpIn the submenu, move between the three.
EnterSpaceIn the submenu, choose, close every level and give focus back to the trigger.
LeftEscapeClose the submenu alone, with focus back on Theme. Right in right to left.

Design rules

What keeps the theme a device preference.

Put System first: it is the default.

Apply a choice at once, in the same frame.

Read the cookie on the server, so the first paint is right.

Pass null until you know the choice.

Never make the theme part of a form or a SaveBar.

Never show a Changed chip for it.

Never add a script for it to a page with a strict content security policy.

esc

↑ ↓ to move↵ to open