feat(theme): tri-state mode (system/light/dark) with live OS sync#5412
Merged
MarkusNeusinger merged 1 commit intomainfrom Apr 25, 2026
Merged
feat(theme): tri-state mode (system/light/dark) with live OS sync#5412MarkusNeusinger merged 1 commit intomainfrom
MarkusNeusinger merged 1 commit intomainfrom
Conversation
- Rewrites useThemeMode to expose `{ mode, effective, isDark, setMode, cycle }`
- `system` is the new default and writes nothing to localStorage, so a fresh
visit follows `prefers-color-scheme` and live-flips when the OS theme changes
- Explicit `light`/`dark` persists; cycling back to `system` clears storage
- ThemeToggle becomes a tri-state cycle (◑ system → ☀ light → ☾ dark → …)
- `theme_toggle` analytics event now reports the next mode (`system` |
`light` | `dark`); ambient `theme` prop still tracks the *effective* theme
so existing Plausible breakdowns keep working
- Inline pre-paint script in index.html sets `data-theme` before React hydrates
to avoid a flash-of-wrong-theme
- Tests cover all four transitions, live OS change, and OS-ignore-while-explicit
Closes #5406
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Contributor
There was a problem hiding this comment.
Pull request overview
This PR implements a tri-state theme mode (system / light / dark) across the frontend, enabling live OS theme syncing when in system mode, while preserving explicit user choices via localStorage and avoiding a flash-of-wrong-theme before React hydration.
Changes:
- Rewrites
useThemeModeinto a tri-state hook exposing{ mode, effective, isDark, setMode, cycle }and re-enables liveprefers-color-schemeupdates. - Updates the UI + analytics to reflect tri-state cycling (
theme_toggle.to = system|light|dark) while keeping the ambientthemeprop as the effective theme. - Adds a pre-paint theme resolver script in
index.htmland updates tests/docs accordingly.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/reference/plausible.md | Documents tri-state theme_toggle.to and clarifies ambient theme as effective theme |
| app/src/hooks/useThemeMode.ts | Implements tri-state theme state, OS sync listener, persistence rules |
| app/src/hooks/useThemeMode.test.ts | Rewrites tests to cover system default, cycling, OS-sync behavior, persistence/clearing |
| app/src/hooks/useLayoutContext.ts | Expands ThemeContextValue to include mode, effective, setMode, cycle |
| app/src/components/ThemeToggle.tsx | Updates toggle UI to tri-state and adjusts aria-labeling |
| app/src/components/RootLayout.tsx | Sets analytics ambient theme prop from effective theme |
| app/src/components/RootLayout.test.tsx | Updates useTheme mock to new shape and verifies ambient prop behavior |
| app/src/components/MastheadRule.tsx | Emits theme_toggle with the next mode and cycles theme |
| app/src/components/MastheadRule.test.tsx | Updates assertions for tri-state label + event payload |
| app/index.html | Adds inline pre-paint script to set data-theme before hydration |
| app/eslint.config.js | Adds MediaQueryListEvent to eslint globals for TS source linting |
Comment on lines
+10
to
+25
| function readStoredMode(): ThemeMode { | ||
| if (typeof window === 'undefined') return 'system'; | ||
| const stored = localStorage.getItem(STORAGE_KEY); | ||
| return stored === 'dark' || stored === 'light' ? stored : 'system'; | ||
| } | ||
|
|
||
| function systemPrefersDark(): boolean { | ||
| return typeof window !== 'undefined' | ||
| && typeof window.matchMedia === 'function' | ||
| && window.matchMedia('(prefers-color-scheme: dark)').matches; | ||
| } | ||
|
|
||
| function persistMode(mode: ThemeMode): void { | ||
| if (mode === 'system') localStorage.removeItem(STORAGE_KEY); | ||
| else localStorage.setItem(STORAGE_KEY, mode); | ||
| } |
Comment on lines
+42
to
+44
| export type ThemeMode = 'system' | 'light' | 'dark'; | ||
| export type EffectiveTheme = 'light' | 'dark'; | ||
|
|
| const next = NEXT_MODE[mode]; | ||
| return ( | ||
| <Box | ||
| component="button" |
Comment on lines
+112
to
+115
| const NEXT_MODE = { system: 'light', light: 'dark', dark: 'system' } as const; | ||
| const handleThemeToggle = () => { | ||
| trackEvent('theme_toggle', { to: isDark ? 'light' : 'dark' }); | ||
| toggle(); | ||
| trackEvent('theme_toggle', { to: NEXT_MODE[mode] }); | ||
| cycle(); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #5406.
Summary
useThemeModeto a tri-state hook returning{ mode, effective, isDark, setMode, cycle }withmode: 'system' | 'light' | 'dark'.systemis the new default and writes nothing tolocalStorage, so a fresh visit followsprefers-color-schemeand live-flips when the OS theme changes (e.g. iOS sunset auto-dark) — fixing the deadmatchMedialistener.light/darkpersists inlocalStorage; cycling back tosystemclears the key so the next visit again defaults to OS-following.ThemeTogglebecomes a tri-state cycle:◑ system → ☀ light → ☾ dark → system.index.htmlresolves the theme before React hydrates to avoid a flash-of-wrong-theme.Analytics
theme_toggle.tonow reports the new mode (system/light/dark) so we can see how often users opt back into system tracking.themepageview prop still reports the effective theme (dark/light), so existing Plausible breakdowns keep working.docs/reference/plausible.mdupdated.Tests
useThemeMode.test.tsrewritten to cover:systemand persists nothingsystem → light → dark → systemtransitions (storage written / cleared correctly)systemflips effective themelight/darkis ignoredsetModedirect pathlocalStorageon remountAcceptance criteria checklist
light/darkinlocalStoragesystemmode andlocalStorageis clearedsystem, OS-level theme changes during the session flip the page livelight/dark, OS changes are ignoredthemeambient analytics prop reflects the effective themetheme_toggleevent captures the new mode{ mode, effective, setMode, cycle })index.htmlTest plan
yarn type-checkcleanyarn test— 374/374 passyarn buildsucceedslocalStorage.themecleared and verify OS preference is honored, that toggling iOS/macOS dark/light mode flips the tab live, and that the cycle button takes you back tosystem(clearing storage)localStorage.theme = 'dark'set and verify OS changes are ignoredhttps://claude.ai/code/session_01W3JkYodBsG9p5vB6y7LDPG
Generated by Claude Code