Source: https://monarchui.dev/components/theme-toggle/

Components · Advanced interaction

# ThemeToggle

Light, dark or system theme, saved in the shared rook_theme cookie and applied like the pre-paint script.

@rookcom/monarch-react/shell

Component

## Appearance setting

## Usage examples

### Menu button, with and without text

### Controlled, translated

## Purpose

Lets a person choose the Rook theme once and have every Rook app follow it. The choice lives in the `rook_theme` cookie (on `.rookhq.com` by default) and is applied to `<html>` exactly as `appearanceScript()` from `@rookcom/monarch-tokens` applies it before first paint. Needed by: app, calendar, friends, signal.

### Use when

- The theme choice in a rail, an account menu or a toolbar (`variant="menu"`).
- An appearance setting on a preferences page (`variant="segmented"`).

### Avoid when

- Previewing a theme inside one panel without changing the app: set `data-mn-theme` on that panel instead.
- Public, customer-facing pages that must stay light: scope dark mode with `data-rook-theme-routes` rather than hiding the toggle.

## Design principles

- Three choices, always including System, so the OS setting is respected (one of the two capabilities the registry found missing).
- One source of truth: the shared cookie. The toggle never keeps a second copy that could disagree with another app.
- Hydration-safe: the server renders `defaultValue`, the client switches to the saved choice through `useSyncExternalStore`, and the pre-paint script has already painted the right theme.
- Cookie I/O is injectable (`cookies`), so tests, docs and embedded frames never touch the real cookie.

## API reference

```tsx
import { ROOK_THEME_COOKIE, ROOK_THEME_EVENT, ThemeToggle, applyThemePreference } from '@rookcom/monarch-react/shell';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `ROOK_THEME_COOKIE` | utility | client | – |
| `ROOK_THEME_EVENT` | utility | client | – |
| `ThemeToggle` | component | client | `.mn-theme-toggle` |
| `applyThemePreference` | utility | client | – |

`applyThemePreference(preference, root?)` applies a preference the same way the toggle does and returns the resolved theme. Use it after reading the cookie on your own.

`ROOK_THEME_COOKIE` (`rook_theme`) and `ROOK_THEME_EVENT` (`rook-theme`, fired on `window` after every change) are the cross-app contract.

`ShellCookieStore` is `{ get(name), set(name, value, { maxAge, domain }) }`.

Generated from the TypeScript declarations of `packages/react/src/components/interaction/theme-toggle`.

## Properties

Controlled with `value` + `onValueChange`, or uncontrolled (reads and writes the cookie).

`cookies={null}` keeps the choice in memory for this page only; `applyToDocument={false}` leaves `<html>` alone.

### `ThemeToggle`

Light, dark or system theme for Rook apps. The choice is saved in the shared `rook_theme` cookie (so every Rook app follows it) and applied to `<html>` the same way the tokens pre-paint script does, so render that script in `<head>` and this toggle anywhere. It renders the default on the server and picks up the saved choice on hydration, so there is no mismatch.

| Prop | Type | Description |
| --- | --- | --- |
| `align` | `"center" \| "start" \| "end"` | `menu` only: alignment against the trigger. Default `start`. |
| `applyToDocument` | `boolean` | Apply the choice to `<html>` (data-mn-theme, `.dark`, color-scheme). Default true. |
| `className` | `string` |  |
| `cookieDomain` | `string` | Cookie domain. Default: `.rookhq.com` on Rook hosts (shared across apps), otherwise the current host only. |
| `cookieName` | `string` | Cookie name. Default `rook_theme`, shared by every Rook app. |
| `cookies` | `ShellCookieStore \| null` | Where the choice is stored. Default `document.cookie`; `null` keeps it in memory only. |
| `defaultValue` | `ThemePreference` | Used when there is no cookie yet, and for the server render. Default `light`, the Rook default. |
| `id` | `string` |  |
| `messages` | `Partial<ThemeToggleMessages>` | Interface text, for translation. |
| `onValueChange` | `((value: ThemePreference) => void)` | Called with the new preference after it is saved and applied. |
| `showLabel` | `boolean` | `menu` only: show the current choice as text next to the icon. Default true. |
| `side` | `"top" \| "right" \| "bottom" \| "left"` | `menu` only: where the menu opens. Default `bottom`; use `right` in a rail. |
| `size` | `"sm" \| "md"` |  |
| `value` | `ThemePreference` | The preference (controlled). Without it the toggle reads and writes the cookie itself. |
| `variant` | `"menu" \| "segmented"` | `segmented`: three visible choices (settings pages). `menu`: one button that opens a Light / Dark / System menu (rails, toolbars, account menus). Default `segmented`. |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `variant` | `'segmented'``'menu'` | `segmented` shows the three choices as a radio group; `menu` is one button opening a radio menu. Default `segmented`. |
| `showLabel` | `'true'``'false'` | `menu` only: show the current choice next to the icon. Default true. |
| `size` | `'sm'``'md'` | Control height. Default `md`. |

## States

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Current choice | `data-value="light\|dark\|system"` | On the root. The segmented choice is `aria-checked`; the menu item is a checked `menuitemradio`, and the trigger names it ("Theme: Dark"). |
| Applied theme | `<html data-mn-theme class="dark" style="color-scheme">` | Written on change. `system` resolves through `prefers-color-scheme` and follows OS changes while the page is open. |
| Out-of-scope route | `<html data-rook-theme-routes="regex">` | Routes that do not match stay light, matching the pre-paint script. |

## Interaction behaviour

- Choosing saves the cookie for a year, applies the theme to `<html>`, fires `rook-theme` and calls `onValueChange`.
- Every toggle on the page re-reads the cookie on `rook-theme`, on window focus and when the tab becomes visible, so a change made in another Rook app shows up when you come back.
- The contrast preference (`data-mn-contrast`) is kept as it is.

## Keyboard support

| Keys | Action |
| --- | --- |
| Tab | Moves to the selected choice (segmented) or the menu button. |
| ← → ↑ ↓ | Segmented: move between choices and select. Menu: move between items. |
| Enter / Space | Menu: open the menu, or choose the focused item. |
| Escape | Menu: close it and return focus to the button. |

## Accessibility

Role

radiogroup (segmented) or a menu button with menuitemradio items (menu)

Accessible name

messages.label

(default "Theme"); the menu button adds the current choice: "Theme: System".

- Built on SegmentedControl and DropdownMenu from `@rookcom/monarch-react`, which follow the APG radio group and menu button patterns.
- Icons are decorative; every choice has a text label, also when the menu button hides its text.
- Changing the theme does not move focus.

## Responsive behaviour

- The segmented control never grows wider than its container.
- In a collapsed rail use `variant="menu"` with `showLabel={false}`; the menu opens to the side you choose (`side="right"`).

## Theme support

- Uses the theme of the surface it sits on: tokens only, so light, dark and more-contrast all apply.
- Pair with `appearanceScript({ themeCookie: "rook_theme" })` in `<head>` for a flash-free first paint.

## Do and don't

### Do

- Render the tokens pre-paint script in `<head>` of every app that shows the toggle.
- Keep the default cookie name and domain in Rook apps, so the choice follows people between apps.
- Pass `cookieDomain` for apps on other domains, and a custom `cookies` store in tests.

### Don't

- Do not read `rook_theme` and toggle `.dark` yourself; call `applyThemePreference` so scope and contrast stay right.
- Do not offer only light and dark; System is part of the contract.

## Related components

- [RookRail](https://monarchui.dev/components/rook-rail/): Has a slot for the theme toggle.
- [SegmentedControl](https://monarchui.dev/components/segmented-control/): The control the segmented variant is built on.
- [DropdownMenu](https://monarchui.dev/components/dropdown-menu/): The menu the menu variant is built on.
- Brand Theme Engine (stable): Product accents, which the theme combines with.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `@rookcom/monarch-tokens``react` |
| Monarch components | [Button](https://monarchui.dev/components/button/), [DropdownMenu](https://monarchui.dev/components/dropdown-menu/), [SegmentedControl](https://monarchui.dev/components/segmented-control/) |

## Testing coverage

- Labelled radio group with the saved choice checked; unknown cookie values fall back to the default.
- Choosing writes the cookie (name, one-year max-age, domain), applies the theme to `<html>` and calls `onValueChange`; cookie name and domain are configurable.
- Arrow keys move and select; controlled mode follows the value prop only.
- Menu variant: the button names the current choice, the menu has checked radio items, choosing applies; hidden text and translated messages.
- `system` follows the OS, `data-rook-theme-routes` keeps other routes light, contrast is kept.
- Server render uses the default and hydration switches to the saved choice without errors.

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/theme-toggle; Cross-browser axe: /components/theme-toggle |

Tests: `packages/react/src/components/interaction/theme-toggle`

## All example code

### Appearance setting

```tsx
import { useState } from 'react';
import { ThemeToggle, type ShellCookieStore, type ThemePreference } from '@rookcom/monarch-react/shell';

/** Docs examples keep their choice in memory, so they never change this site's own theme. */
function useDemoCookies(initial?: ThemePreference): ShellCookieStore {
  const [store] = useState<ShellCookieStore>(() => {
    const jar = new Map<string, string>(initial ? [['rook_theme', initial]] : []);
    return { get: (n) => jar.get(n) ?? null, set: (n, v) => void jar.set(n, v) };
  });
  return store;
}

function SettingsDemo() {
  const cookies = useDemoCookies('system');
  return <ThemeToggle cookies={cookies} applyToDocument={false} />;
}

<SettingsDemo />
```

### Menu button, with and without text

```tsx
import { useState } from 'react';
import { ThemeToggle, type ShellCookieStore, type ThemePreference } from '@rookcom/monarch-react/shell';

/** Docs examples keep their choice in memory, so they never change this site's own theme. */
function useDemoCookies(initial?: ThemePreference): ShellCookieStore {
  const [store] = useState<ShellCookieStore>(() => {
    const jar = new Map<string, string>(initial ? [['rook_theme', initial]] : []);
    return { get: (n) => jar.get(n) ?? null, set: (n, v) => void jar.set(n, v) };
  });
  return store;
}

function MenuDemo() {
  const cookies = useDemoCookies('dark');
  return (
    <div style={{ display: 'flex', gap: 'var(--mn-space-3)', alignItems: 'center' }}>
      <ThemeToggle variant="menu" cookies={cookies} applyToDocument={false} />
      <ThemeToggle variant="menu" showLabel={false} cookies={cookies} applyToDocument={false} />
    </div>
  );
}

<MenuDemo />
```

### Controlled, translated

```tsx
import { useState } from 'react';
import { ThemeToggle, type ThemePreference } from '@rookcom/monarch-react/shell';

function ControlledDemo() {
  const [theme, setTheme] = useState<ThemePreference>('light');
  return (
    <div style={{ display: 'grid', gap: 'var(--mn-space-2)' }}>
      <ThemeToggle
        size="sm"
        value={theme}
        onValueChange={setTheme}
        cookies={null}
        applyToDocument={false}
        messages={{ label: 'Appearance' }}
      />
      <p style={{ margin: 0, color: 'var(--mn-color-text-muted)', fontSize: 'var(--mn-text-sm)' }}>
        Saved preference: {theme}
      </p>
    </div>
  );
}

<ControlledDemo />
```
