Dark Mode
ZUI uses the CSS light-dark() function for all semantic colour tokens, which means it respects prefers-color-scheme automatically with no JavaScript required.
The @mrmartineau/zui/utils package provides utilities to let users override their system preference at runtime, persisted to localStorage.
How it works
When a user selects a preference, ZUI adds .zui-dark or .zui-light to <html>. These classes set the CSS color-scheme property, which controls how light-dark() resolves — overriding the system preference.
:root.zui-dark { color-scheme: dark; }
:root.zui-light { color-scheme: light; }
No prefers-color-scheme media queries, no duplicated token overrides. All existing light-dark() values just work.
Preventing flash of wrong theme
Add this blocking script before any stylesheets in your <head>. It runs before paint and applies the stored preference immediately:
<script>(function(){var s=localStorage.getItem('zui-color-scheme');if(s==='dark')document.documentElement.classList.add('zui-dark');else if(s==='light')document.documentElement.classList.add('zui-light');})()</script>
You can also import it as a string for SSR frameworks:
import { headScript } from '@mrmartineau/zui/utils'
Next.js (App Router)
import { headScript } from '@mrmartineau/zui/utils'
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en">
<head>
<script dangerouslySetInnerHTML={{ __html: headScript }} />
</head>
<body>{children}</body>
</html>
)
}
Astro
---
import { headScript } from '@mrmartineau/zui/utils'
---
<html>
<head>
<script is:inline set:html={headScript} />
</head>
</html>
API
initColorScheme()
Reads the stored preference from localStorage and applies the appropriate class to <html>. Call this once on app boot (after the headScript covers the blocking case).
import { initColorScheme } from '@mrmartineau/zui/utils'
initColorScheme()
setColorScheme(scheme)
Sets the colour scheme and persists it to localStorage.
import { setColorScheme } from '@mrmartineau/zui/utils'
setColorScheme('dark') // force dark
setColorScheme('light') // force light
setColorScheme('system') // follow system preference
getColorScheme()
Returns the current stored preference ('dark', 'light', or 'system').
import { getColorScheme } from '@mrmartineau/zui/utils'
const scheme = getColorScheme() // 'dark' | 'light' | 'system'
React hook
The useColorScheme hook is available from @mrmartineau/zui/react. It re-renders whenever the scheme changes, including changes made outside React (e.g. from a plain JS toggle button).
import { useColorScheme } from '@mrmartineau/zui/react'
function ThemeToggle() {
const { scheme, set } = useColorScheme()
return (
<button onClick={() => set(scheme === 'dark' ? 'light' : 'dark')}>
{scheme === 'dark' ? 'Switch to light' : 'Switch to dark'}
</button>
)
}
The set function is the same as setColorScheme — calling it from anywhere dispatches a zui-color-scheme-change event that keeps all hook instances in sync.