Skip to main content

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)}
/>
);
}
ReturnsType
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

Use themeScript too

A 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.