Themes
Four families — kanzo, catppuccin, lofi and nord — each a day theme and a night theme. How to offer them, set the default, let people choose, theme a region, and write your own.
A theme is one mode, so the library ships families: a light theme and a dark one that belong
together, the pair a person wears by day and by night. Four families ship — eight themes — and
kanzo is the default.
| Family | Day | Night | Source |
|---|---|---|---|
kanzo | kanzo | kanzo-dark | the kanzo.tech brand |
catppuccin | catppuccin-latte | catppuccin-mocha | Catppuccin's Latte and Mocha |
lofi | lofi | lofi-dark | daisyUI's lofi, and its inversion |
nord | nord | nord-dark | Nord's Snow Storm and Polar Night |
kanzo is drawn from the brand: deep green #004011 by day, neon #1AE973 by night, mist
#E0E7DF for the quiet surfaces, and Geist standing in for the commercial Neue Haas Grotesk.
Each card is the family painting a miniature app screen — ThemePreview,
the same one the theme cards in Preferences draw — with a day/night toggle inside it. Wear it puts the family on
both sides of this site; Edit opens the generator on the pair.
How a theme is chosen
Three facts, and everything on this page follows from them:
- A theme is a side. Each file is one
[data-theme="<name>"]block with its owncolor-scheme.nord-darkis not a mode ofnord; it is another theme. - A person chooses one theme per side, plus which side is worn — GitHub's
Appearance model. The preference is
themeByAppearance: { light?, dark? }besideappearance: "light" | "dark", which starts at the OS'sprefers-color-schemeuntil the person picks. - The provider writes the result to
<html>.data-themegets the theme for the side being worn, and.darkthe side. Every component reads the tokens, so nothing re-renders to re-skin.
Enabling themes
There is nothing to enable. @kanzo-tech/ui/tailwind.css imports @kanzo-tech/theme/themes.css,
which carries all eight blocks — about sixty declarations each — so every theme is already in the
page and switching costs no request.
What a product offers is a different question, and it is the provider's themes prop. It
defaults to themeIndex, every shipped theme; narrow it and the picker, the retirement check and the
default pair all follow:
import { themeIndex } from "@kanzo-tech/theme";
import { KanzoThemeProvider } from "@kanzo-tech/ui";
const offered = themeIndex.filter((t) => t.family === "kanzo" || t.family === "nord");
<KanzoThemeProvider themes={offered}>{children}</KanzoThemeProvider>;A theme left out stays in the stylesheet and is inert: no control offers it, and a person who had chosen it is moved to the default and told once (see retired themes).
Setting the default
defaultTheme is the pair worn while a person has chosen nothing — a pair, never one name,
because one name would paint a night theme in daylight. It defaults to defaultThemePair(themes):
the first light theme listed and its family's dark one, which for the shipped catalogue is
{ light: "kanzo", dark: "kanzo-dark" }.
const pair = { light: "nord", dark: "nord-dark" };
<script dangerouslySetInnerHTML={{ __html: themeScript({ defaultTheme: pair }) }} />
<KanzoThemeProvider defaultTheme={pair}>{children}</KanzoThemeProvider>Give themeScript the same defaultTheme (and policy)
The script paints <html> before React runs. If it falls back to kanzo while the provider falls
back to nord, the page flashes one family and hydrates into the other — and any control whose
markup depends on the theme reports a hydration mismatch.
Letting people choose
Preferences draws the control first: a
Light theme card and a Dark theme card — choosing a card wears its side — each a large
preview of its choice over a row of round swatches, one per theme. Pointing at a swatch previews it
in its card; clicking it chooses it and wears that side. It offers exactly what themes lists.
<KanzoThemeProvider>
<PreferencesSections namespace="theme" />
</KanzoThemeProvider>To fix the brand instead — a white-label instance whose members do not choose — withhold the axis in the tenant's policy. The picker then offers the mode alone:
<KanzoThemeProvider defaultTheme={pair} policy={{ theme: { themeByAppearance: { hidden: true } } }}>The full recipe, including a tenant's own family loaded from configuration, is in Theming → White-label.
Theming a region
[data-theme="nord"] is an ordinary attribute selector — it matches an element, not a document —
so a theme's custom properties land wherever the attribute is and inherit into everything inside it.
Each card above is exactly that.
<div className="dark" data-theme="nord-dark" style={{ colorScheme: "dark" }}>
<YourScreen />
</div>A region cannot theme Ark's overlays — Dialog, Popover, Menu and the rest portal to document.body —
so it is for previews and branded panels, never for an app's chrome. The side's class beside the attribute —
dark here — is what keeps the region from wearing the light block on a dark page.
The default theme binds as :root:not([data-theme])
Not a bare :root, which has the same specificity as [data-theme="x"]: the tie would break on
source order. themes.test.ts, "binds the default theme so it cannot outrank a chosen one", holds
it.
Adding a custom theme
A family is two flat blocks. Write them in the theme generator — it edits the
light and dark sides together, proposes each ink from its fill, and warns live on contrast — or copy
the nearest shipped pair from @kanzo-tech/theme/themes/ and change the values.
/* @family acme
@label Acme */
[data-theme="acme"] {
color-scheme: light;
--background: #fbfcfd;
--foreground: #10151c;
--primary: #1f6feb;
--primary-foreground: #ffffff;
/* …the rest of the twenty-one, the authored extras, the shape knobs and the eight --syntax-* */
}Load the CSS after the library's stylesheet and list both sides in themes:
const ACME: ThemeOption[] = [
{ value: "acme", label: "Acme", dark: false, family: "acme" },
{ value: "acme-dark", label: "Acme Dark", dark: true, family: "acme" },
];
<KanzoThemeProvider themes={[...ACME, ...themeIndex]}>{children}</KanzoThemeProvider>;A tenant's family first in the list makes it the default pair. Every value a theme may declare, and which ones it must, is in Theming → Tokens; the per-client walkthrough is Onboarding a tenant.
Adjusting a shipped theme
A rule of your own, loaded after the library's sheet, wins on source order — the specificity is the same:
[data-theme="nord"] {
--primary: #5e81ac;
--radius-box: 0.25rem;
}Change a fill and its inks may stop clearing AA. For more than a nudge, open the family in the generator (Edit on its card) and ship the result as your own family.
CSS variables
A theme authors these; every other colour name in the vocabulary is a use of one of them, bridged
once in tokens.css.
| Group | Variables |
|---|---|
| Surfaces and ink | --background --foreground --card --muted --muted-foreground |
| Brand | --primary --secondary --accent, each with its -foreground |
| Status | --destructive --info --success --warning, each with -content (on the fill) and -foreground (on the page) |
| Lines | --border --ring --input |
| Read with no fallback | --field --faint, and --popover where an overlay lifts off the card |
| Editor | --syntax-keyword --syntax-string --syntax-number --syntax-function --syntax-variable --syntax-property --syntax-type --syntax-annotation |
| Charts | --chart-1 … --chart-8, --chart-capacity |
| Shape | --radius-box --radius-field --radius-selector --size-field --size-selector --stroke --relief |
What each one is for, and how the editor's tags map onto the syntax inks: Theming.
Every theme clears WCAG AA, syntax included
CONTRAST_PAIRS in @kanzo-tech/theme lists the pairs a theme owes: every ink on the surface it is
read on at 4.5:1, each of the eight --syntax-* inks on the editor's paper and on its active
line, and the brand fill, the focus ring, a field's border and every chart slot at 3:1 against the
page. themes.test.ts holds all eight shipped themes to it in CI, and the generator warns from the
same list, so a theme it calls clean is one the build accepts. The full list.
The theme generator
/theme-generator is daisyUI's generator, reshaped for pairs.
Theme generator
Author a light and dark pair in OKLCH, watch both sides, copy the CSS or the instance config.
Theming
The model, the provider, white-label policy, every token and the contrast floors.
Editing a pair
Start from seeds both sides from a shipped family; Family names yours (acme writes acme
and acme-dark). The sun/moon switch at the top of the rail — or a click on either preview — picks
which side the colour pickers edit. Radius, size, stroke and relief are decisions about the product
rather than a side, so they are shared and written to both blocks. Reset reads the starting
family again.
The rail is grouped by role, one OKLCH picker per authored colour: surfaces and their two inks, the
three brand fills with their inks, the four status families with -content and -foreground, the
lines, and the eight syntax inks. OKLCH because moving perceptual lightness is what changes contrast
without dragging the hue. --popover, --field, --faint and the chart set have no picker and are
carried from the family you started from, so the output is always a whole theme.
It proposes, and it does not decide
Pick a fill and its inks are proposed: -content sits on the fill (inkFor — daisyUI's rule
recovered from its output, with 4.5:1 as a floor), -foreground is the same family read on the
page (pageInk). An ink still equal to its proposal follows the fill; change it and it stops
following. The link mark beside an ink says which it is, and clicking a broken link takes the proposal
back.
It warns, and it does not refuse
The contrast panel lists every pair below its floor, on both sides, by token and ratio — and a warning mark rides on the side switch, so a problem on the side you are not editing is still visible. Red is a fact, not a veto: what checks the artefact is the guard over the shipped files.
The preview cannot disagree with the output
Each side's values are inline custom properties on its own element, and an inline declaration
block is exactly what a [data-theme] rule is: the same properties, the same cascade. The previews
are real tokens read by real components, and the CSS the page hands you is those style attributes
reformatted — there is no serialiser to be wrong.
What it hands you
- Theme CSS — two blocks, each with its
@family/@labelheader. In this repository each block is its ownpackages/theme/themes/<name>.css; then runpnpm --filter @kanzo-tech/theme gen. Anywhere else, load it after the library's stylesheet. - Instance config — the same CSS plus the families offered, the default and the lock, as a
branding:YAML block a host reads as-is. The recipe shows how a host maps it onto the provider. - The link — Copy link puts the whole pair in the URL fragment (
#theme=…, deflate-compressed). A fragment never reaches a server, so a client's unreleased palette never leaves their machine. Links made before pairs still open, as the side they declared, with the other side seeded from the default family.