Skip to content

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/shellComponent

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

import { ROOK_THEME_COOKIE, ROOK_THEME_EVENT, ThemeToggle, applyThemePreference } from '@rookcom/monarch-react/shell';
ExportKindRenderingRoot class
ROOK_THEME_COOKIEutilityclient–
ROOK_THEME_EVENTutilityclient–
ThemeTogglecomponentclient.mn-theme-toggle
applyThemePreferenceutilityclient–

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.

PropTypeDescription
align"center" | "start" | "end"`menu` only: alignment against the trigger. Default `start`.
applyToDocumentbooleanApply the choice to `<html>` (data-mn-theme, `.dark`, color-scheme). Default true.
classNamestring
cookieDomainstringCookie domain. Default: `.rookhq.com` on Rook hosts (shared across apps), otherwise the current host only.
cookieNamestringCookie name. Default `rook_theme`, shared by every Rook app.
cookiesShellCookieStore | nullWhere the choice is stored. Default `document.cookie`; `null` keeps it in memory only.
defaultValueThemePreferenceUsed when there is no cookie yet, and for the server render. Default `light`, the Rook default.
idstring
messagesPartial<ThemeToggleMessages>Interface text, for translation.
onValueChange((value: ThemePreference) => void)Called with the new preference after it is saved and applied.
showLabelboolean`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"
valueThemePreferenceThe 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

PropValuesDescription
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

StateExposed asBehaviour
Current choicedata-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

KeysAction
TabMoves to the selected choice (segmented) or the menu button.
← → ↑ ↓Segmented: move between choices and select. Menu: move between items.
Enter / SpaceMenu: open the menu, or choose the focused item.
EscapeMenu: 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.
  • 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

KindDepends on
npm packages@rookcom/monarch-tokensreact
Monarch componentsButton, 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 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.
CheckCovered
Axe in jsdomYes
Server renderingYes
Browser suitesVisual baselines: /__gallery/theme-toggle; Cross-browser axe: /components/theme-toggle

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