Color mode

color-mode

Light / dark / system color-mode switching for Riebeckite sites. The control writes to data-theme on <html> at runtime, exactly the attribute the themes' CSS uses to pick a palette — so it works with every built-in theme and needs no JavaScript in the theme itself.

日本語

Overview

Themes derive their palette from three CSS states (see theme-system.md):

  • :root — light
  • :root[data-theme="dark"] — dark
  • @media (prefers-color-scheme: dark) { :root:not([data-theme]) } — follow the OS ("system")

The plugin is a thin runtime switch over that contract:

  • ColorModeScript — a one-line inline script for <head> that applies the saved mode before first paint, preventing a flash of the wrong theme.
  • ColorModeToggle — a segmented control with light / dark / system buttons.
  • initColorMode — the client entry that binds the buttons, persists the choice in localStorage and keeps the document in sync.

colorModePlugin() itself only registers the stylesheet and the client entry; everything else is delivered through the two components you render in your site shell.

Usage

ts
import { defineConfig } from "@riebeckite/core";
import { colorModePlugin } from "@riebeckite/plugin-color-mode";
 
export default defineConfig({
  // ...
  plugins: [colorModePlugin()],
});

colorModePlugin() bundles style.css and declares initColorMode as a client entry.

Render the components

tsx
import { ColorModeScript, ColorModeToggle } from "@riebeckite/plugin-color-mode";
import { ThemeRoot } from "@riebeckite/honox/ui";
 
// ...in your renderer
return (
  <ThemeRoot theme={config.theme}>
    <head>
      <ColorModeScript />
      {/* stylesheets, ... */}
    </head>
    <body>
      <header>
        <ColorModeToggle />
      </header>
      {children}
    </body>
  </ThemeRoot>
);

<ColorModeScript /> must be rendered in <head> ahead of the stylesheets so the mode is set before CSS is applied. <ColorModeToggle /> can go anywhere; positions in headers and navbars are typical.

Props

ColorModeScript

Prop Description
storageKey localStorage key to read from. Defaults to riebeckite-color-mode.

ColorModeToggle

Prop Description
storageKey localStorage key to persist under. Defaults to riebeckite-color-mode.
modes Modes to offer, in order. Defaults to ["light", "dark", "system"].
labels Per-mode aria-label overrides: { light?, dark?, system? }.
label Group aria-label. Defaults to "Color mode".

Anything you configure here must be reflected on both components if you change the storage key.

How the mode is decided

  1. On first paint, ColorModeScript applies the persisted value if it is a valid mode (light, dark or system); otherwise it leaves the data-theme the server already emitted (from your theme's colorMode).
  2. On load, initColorMode repeats the same logic and syncs the buttons' aria-pressed state.
  3. Clicking a button persists the choice and re-applies it immediately. "system" removes the data-theme attribute, so the OS preference CSS takes over.

Since themes are presentation-only, the SSRed data-theme comes from your theme's colorMode. The plugin only overrides it when the visitor has chosen otherwise.

CSS hooks

Hook Purpose
rr-color-mode Root container of the toggle.
rr-color-mode__button A single mode button.
rr-color-mode__icon The inline SVG icon.

The stylesheet uses --rb-color-* tokens, so the control follows the active theme. The root is display: none until initColorMode (or the inline script) sets data-rb-color-mode="ready" on <html>: with JavaScript disabled the control never appears.

Accessibility

  • The container is a <fieldset> whose <legend> names the group (rendered visually hidden).
  • Each button is type="button" with an aria-label naming its mode; the active one carries aria-pressed="true" set by the runtime.
  • Icons are decorative (aria-hidden); labels come from the buttons.
  • Transitions are disabled under prefers-reduced-motion.

Events

initColorMode dispatches a CustomEvent on document:

ts
document.addEventListener("riebeckite:color-mode", (event) => {
  event.detail.mode; // "light" | "dark" | "system"
});

Nothing consumes it yet. It exists so integrations (for example re-rendering diagrams) can follow mode changes in the future.

Limitations

  • Diagram plugins (d2, mermaid, vega-lite) read data-theme once at initialization. After switching modes they keep their previous palette until the page is reloaded. Listening for riebeckite:color-mode is the hook for a future re-render.
  • The inline script is subject to Content-Security-Policy. If your script-src forbids inline scripts, allow it by nonce or hash.
  • localStorage access is wrapped in try/catch; when storage is unavailable the switch still works for the current page but is not persisted.
  • Multiple toggles on one page should share the same storageKey.

Exports

  • colorModePlugin() — plugin factory
  • ColorModeToggle — the switch component
  • ColorModeScript — the before-paint inline script component
  • initColorMode — browser init (also exported by @riebeckite/plugin-color-mode/client)
  • Constants: COLOR_MODES, COLOR_MODE_STORAGE_KEY, COLOR_MODE_EVENT (and friends)
  • Type: ColorMode

See also

History

1 changesCollapseExpand
1 + <!-- Generated from packages/plugins/color-mode/README.md. Do not edit this page directly; edit the package README and run `pnpm docs:sync`. -->
2 +
3 + # color-mode
4 +
5 + Light / dark / system color-mode switching for Riebeckite sites. The control
6 + writes to `data-theme` on `<html>` at runtime, exactly the attribute the
7 + themes' CSS uses to pick a palette — so it works with every built-in theme and
8 + needs no JavaScript in the theme itself.
9 +
10 + [日本語](./color-mode.md)
11 +
12 + ## Overview
13 +
14 + Themes derive their palette from three CSS states (see
15 + [theme-system.md](../reference/theme-api.en.md)):
16 +
17 + - `:root` — light
18 + - `:root[data-theme="dark"]` — dark
19 + - `@media (prefers-color-scheme: dark) { :root:not([data-theme]) }` — follow the OS ("system")
20 +
21 + The plugin is a thin runtime switch over that contract:
22 +
23 + - `ColorModeScript` — a one-line inline script for `<head>` that applies the
24 + saved mode **before first paint**, preventing a flash of the wrong theme.
25 + - `ColorModeToggle` — a segmented control with light / dark / system buttons.
26 + - `initColorMode` — the client entry that binds the buttons, persists the
27 + choice in `localStorage` and keeps the document in sync.
28 +
29 + `colorModePlugin()` itself only registers the stylesheet and the client entry;
30 + everything else is delivered through the two components you render in your site
31 + shell.
32 +
33 + ## Usage
34 +
35 + ```ts
36 + import { defineConfig } from "@riebeckite/core";
37 + import { colorModePlugin } from "@riebeckite/plugin-color-mode";
38 +
39 + export default defineConfig({
40 + // ...
41 + plugins: [colorModePlugin()],
42 + });
43 + ```
44 +
45 + `colorModePlugin()` bundles `style.css` and declares `initColorMode` as a client
46 + entry.
47 +
48 + ### Render the components
49 +
50 + ```tsx
51 + import { ColorModeScript, ColorModeToggle } from "@riebeckite/plugin-color-mode";
52 + import { ThemeRoot } from "@riebeckite/honox/ui";
53 +
54 + // ...in your renderer
55 + return (
56 + <ThemeRoot theme={config.theme}>
57 + <head>
58 + <ColorModeScript />
59 + {/* stylesheets, ... */}
60 + </head>
61 + <body>
62 + <header>
63 + <ColorModeToggle />
64 + </header>
65 + {children}
66 + </body>
67 + </ThemeRoot>
68 + );
69 + ```
70 +
71 + `<ColorModeScript />` must be rendered in `<head>` ahead of the stylesheets so
72 + the mode is set before CSS is applied. `<ColorModeToggle />` can go anywhere;
73 + positions in headers and navbars are typical.
74 +
75 + ### Props
76 +
77 + `ColorModeScript`
78 +
79 + | Prop | Description |
80 + | ---- | ----------- |
81 + | `storageKey` | localStorage key to read from. Defaults to `riebeckite-color-mode`. |
82 +
83 + `ColorModeToggle`
84 +
85 + | Prop | Description |
86 + | ---- | ----------- |
87 + | `storageKey` | localStorage key to persist under. Defaults to `riebeckite-color-mode`. |
88 + | `modes` | Modes to offer, in order. Defaults to `["light", "dark", "system"]`. |
89 + | `labels` | Per-mode `aria-label` overrides: `{ light?, dark?, system? }`. |
90 + | `label` | Group `aria-label`. Defaults to `"Color mode"`. |
91 +
92 + Anything you configure here must be reflected on both components if you change
93 + the storage key.
94 +
95 + ## How the mode is decided
96 +
97 + 1. On first paint, `ColorModeScript` applies the persisted value if it is a
98 + valid mode (`light`, `dark` or `system`); otherwise it leaves the
99 + `data-theme` the server already emitted (from your theme's `colorMode`).
100 + 2. On load, `initColorMode` repeats the same logic and syncs the buttons'
101 + `aria-pressed` state.
102 + 3. Clicking a button persists the choice and re-applies it immediately.
103 + `"system"` **removes** the `data-theme` attribute, so the OS preference CSS
104 + takes over.
105 +
106 + Since themes are presentation-only, the SSRed `data-theme` comes from your
107 + theme's `colorMode`. The plugin only overrides it when the visitor has chosen
108 + otherwise.
109 +
110 + ## CSS hooks
111 +
112 + | Hook | Purpose |
113 + | ---- | ------- |
114 + | `rr-color-mode` | Root container of the toggle. |
115 + | `rr-color-mode__button` | A single mode button. |
116 + | `rr-color-mode__icon` | The inline SVG icon. |
117 +
118 + The stylesheet uses `--rb-color-*` tokens, so the control follows the active
119 + theme. The root is `display: none` until `initColorMode` (or the inline script)
120 + sets `data-rb-color-mode="ready"` on `<html>`: with JavaScript disabled the
121 + control never appears.
122 +
123 + ## Accessibility
124 +
125 + - The container is a `<fieldset>` whose `<legend>` names the group (rendered
126 + visually hidden).
127 + - Each button is `type="button"` with an `aria-label` naming its mode; the
128 + active one carries `aria-pressed="true"` set by the runtime.
129 + - Icons are decorative (`aria-hidden`); labels come from the buttons.
130 + - Transitions are disabled under `prefers-reduced-motion`.
131 +
132 + ## Events
133 +
134 + `initColorMode` dispatches a `CustomEvent` on `document`:
135 +
136 + ```ts
137 + document.addEventListener("riebeckite:color-mode", (event) => {
138 + event.detail.mode; // "light" | "dark" | "system"
139 + });
140 + ```
141 +
142 + Nothing consumes it yet. It exists so integrations (for example re-rendering
143 + diagrams) can follow mode changes in the future.
144 +
145 + ## Limitations
146 +
147 + - Diagram plugins (`d2`, `mermaid`, `vega-lite`) read `data-theme` once at
148 + initialization. After switching modes they keep their previous palette until
149 + the page is reloaded. Listening for `riebeckite:color-mode` is the hook for a
150 + future re-render.
151 + - The inline script is subject to Content-Security-Policy. If your `script-src`
152 + forbids inline scripts, allow it by nonce or hash.
153 + - `localStorage` access is wrapped in `try/catch`; when storage is unavailable
154 + the switch still works for the current page but is not persisted.
155 + - Multiple toggles on one page should share the same `storageKey`.
156 +
157 + ## Exports
158 +
159 + - `colorModePlugin()` — plugin factory
160 + - `ColorModeToggle` — the switch component
161 + - `ColorModeScript` — the before-paint inline script component
162 + - `initColorMode` — browser init (also exported by `@riebeckite/plugin-color-mode/client`)
163 + - Constants: `COLOR_MODES`, `COLOR_MODE_STORAGE_KEY`, `COLOR_MODE_EVENT` (and friends)
164 + - Type: `ColorMode`
165 +
166 + ## See also
167 +
168 + - [Theme system](../reference/theme-api.en.md)
169 + - [`@riebeckite/plugin-ux`](./ux.en.md)
170 +