diff --git a/packages/ui/src/hooks/use-theme.ts b/packages/ui/src/hooks/use-theme.ts index 1c859fb..30080bc 100644 --- a/packages/ui/src/hooks/use-theme.ts +++ b/packages/ui/src/hooks/use-theme.ts @@ -13,15 +13,44 @@ export function getTheme(): Theme { return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light'; } +const DARK_QUERY = '(prefers-color-scheme: dark)'; + +function prefersDark(): boolean { + return typeof window !== 'undefined' && !!window.matchMedia?.(DARK_QUERY).matches; +} + +export function resolveInitialTheme( + stored: Theme | null, + initial: Theme | null | undefined, + systemPrefersDark: boolean, +): Theme { + return stored ?? initial ?? (systemPrefersDark ? 'dark' : 'light'); +} + export function useTheme(initialTheme?: Theme | null) { const [theme, setTheme] = useState( - () => getStoredTheme() || initialTheme || 'light' + () => resolveInitialTheme(getStoredTheme(), initialTheme, prefersDark()) ); useLayoutEffect(() => { document.documentElement.setAttribute('data-theme', theme); }, [theme]); + // Keep following the system until the reader picks a theme themselves. + useEffect(() => { + if (getStoredTheme() || initialTheme || typeof window === 'undefined' || !window.matchMedia) { + return; + } + + const query = window.matchMedia(DARK_QUERY); + const onChange = (event: MediaQueryListEvent): void => { + setTheme(event.matches ? 'dark' : 'light'); + }; + + query.addEventListener('change', onChange); + return () => query.removeEventListener('change', onChange); + }, [initialTheme]); + const toggleTheme = useCallback(() => { setTheme(prev => { const next = prev === 'light' ? 'dark' : 'light'; diff --git a/packages/ui/tests/theme.test.ts b/packages/ui/tests/theme.test.ts new file mode 100644 index 0000000..96e2b41 --- /dev/null +++ b/packages/ui/tests/theme.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { resolveInitialTheme } from '../src/hooks/use-theme'; + +describe('resolveInitialTheme', () => { + it('follows the system when nothing else was chosen', () => { + expect(resolveInitialTheme(null, null, true)).toBe('dark'); + expect(resolveInitialTheme(null, null, false)).toBe('light'); + }); + + it('prefers an explicit flag over the system preference', () => { + expect(resolveInitialTheme(null, 'light', true)).toBe('light'); + expect(resolveInitialTheme(null, 'dark', false)).toBe('dark'); + }); + + it('prefers the reader\'s stored choice over everything else', () => { + expect(resolveInitialTheme('light', 'dark', true)).toBe('light'); + expect(resolveInitialTheme('dark', 'light', false)).toBe('dark'); + }); + + it('treats a missing initial theme the same as a null one', () => { + expect(resolveInitialTheme(null, undefined, true)).toBe('dark'); + }); +});