Dark mode

Dark first, light by one prop, and a toggle for whichever host you are on.

The identity is dark. :root holds the dark palette and .light opts out, which is the opposite of most stylesheets, and it is deliberate: a page that mounts the root and says nothing else comes out on the brand’s ground, not on white.

The root decides

import { Hanzo } from '@hanzo/ui'
<Hanzo theme="light">{children}</Hanzo>

theme is the whole API. The root stamps .t_light or .t_dark on the body, every token resolves against that theme, and the stylesheet’s custom properties answer to the same class. A host that wants the choice to be a person’s keeps it in state and hands it to the root.

A toggle

ThemeToggle from @hanzo/ui/product draws the button and reports the next theme. Controlled, it is one line beside the root:

import { useState } from 'react'
import { Hanzo } from '@hanzo/ui'
import { ThemeToggle, type ThemeMode } from '@hanzo/ui/product'
export function App({ children }) {
const [theme, setTheme] = useState<ThemeMode>('dark')
return (
<Hanzo theme={theme}>
<ThemeToggle theme={theme} onToggle={setTheme} />
{children}
</Hanzo>
)
}

Uncontrolled — <ThemeToggle onThemeChange={fn} /> — it toggles the .dark and .light classes on the document element itself and remembers the choice under the theme key in localStorage, for a host that has no state to keep it in. Either way it shows the target: a sun in dark mode, a moon in light.

Next

Next hosts already run @hanzogui/next-theme, and the toggle’s binding to it is at its own subpath so no other host pulls Next into its graph by importing the product layer.

import { ThemeToggleNext } from '@hanzo/ui/product/theme-toggle-next'
<ThemeToggleNext />

<ThemeToggle /> with no props at all reaches the same binding by dynamic import and falls back to the document toggle where it is absent.

One and Vite

This site keeps the choice with @vxrn/color-scheme, which settles the stored scheme before the first paint and reports it to the root:

import { Hanzo } from '@hanzo/ui'
import { SchemeProvider, useUserScheme } from '@vxrn/color-scheme'
function Site({ children }) {
const { value } = useUserScheme()
return <Hanzo theme={value}>{children}</Hanzo>
}
export default function Layout({ children }) {
return (
<SchemeProvider defaultScheme="dark">
<Site>{children}</Site>
</SchemeProvider>
)
}

useUserScheme also exposes setting — system, dark or light — and set, which is what the toggle in this site’s header drives.

No flash

Whichever host, the theme class has to be on the document before the stylesheet paints, or the page paints dark and repaints light. The Next binding and @vxrn/color-scheme both write it from an inline script; a hand written toggle should do the same. Put suppressHydrationWarning on the html element, since the class the script wrote is not the one React rendered.