Theming
Dark is the default. Light is applied by setting data-theme on <html> — or on any subtree root, to scope it:
<html data-theme="light"></html>
Every token is a CSS custom property, so overriding one is a one-liner:
:root {
--primary: #8b5cf6;
}
useTheme
useTheme drives that attribute for you.
import { useTheme, SegmentedControl, type Theme } from "@kairosis/eidos";
function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<SegmentedControl
label="Theme"
options={["light", "dark", "system"]}
value={theme}
onChange={(next) => setTheme(next as Theme)}
/>
);
}
| Returns | Type | |
|---|---|---|
theme | "light" | "dark" | "system" | What the user chose |
resolved | "light" | "dark" | What's actually applied |
setTheme | (theme: Theme) => void |
Options: storageKey (default "eidos-theme"), persist (default true — turn it off if your app already owns theme state, e.g. next-themes), defaultTheme (default "system"), and element to scope the theme to a subtree instead of <html>.
Three states, not a boolean
"system" has to stay distinct from an explicit "dark". Collapse them and a user who deliberately chose dark gets flipped to light when their OS switches at sunrise — that's the bug every two-state toggle ships. resolved is "system" already resolved, for when you only need to know what's on screen.
Killing the flash
themeScript tooA hook can only run after hydration. By then the page has already painted, so a user who chose light still sees one frame of dark. useTheme on its own cannot fix this.
Put the inline script in <head>, before anything paints:
import { themeScript } from "@kairosis/eidos";
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>;
It reads the stored choice, falls back to the OS preference, and sets data-theme before first paint. If localStorage is unavailable (private mode, blocked cookies) it still applies the system scheme rather than abandoning the page to the default.
Using a custom storageKey? Generate a matching script with createThemeScript("your-key").
How the two themes relate
Light overrides only what actually changes; everything else inherits from :root. Both themes draw from one neutral ramp — light is dark inverted (--text is neutral.100 in dark, neutral.850 in light) — so the two can't drift apart.
--focus-ring is defined once, built from var() references, and re-resolves per theme on its own.