Components · Advanced interaction
ThemeToggle
Light, dark or system theme, saved in the shared rook_theme cookie and applied like the pre-paint script.
Appearance setting
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 />Usage examples
Menu button, with and without text
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
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 />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-themeon that panel instead. - Public, customer-facing pages that must stay light: scope dark mode with
data-rook-theme-routesrather 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 throughuseSyncExternalStore, 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
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>, firesrook-themeand callsonValueChange. - 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"withshowLabel={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
cookieDomainfor apps on other domains, and a customcookiesstore in tests.
Don't
- Do not read
rook_themeand toggle.darkyourself; callapplyThemePreferenceso scope and contrast stay right. - Do not offer only light and dark; System is part of the contract.
Related components
- RookRail: Has a slot for the theme toggle.
- SegmentedControl: The control the segmented variant is built on.
- DropdownMenu: 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-tokensreact |
| Monarch components | Button, DropdownMenu, SegmentedControl |
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 callsonValueChange; 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.
systemfollows the OS,data-rook-theme-routeskeeps 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