Kanzo UI
Getting started

Theming

A theme is one flat block of CSS. The user layers four axes over it with data-* attributes.

Two different things wear the word "theme", and this page is mostly about keeping them apart.

What a client is is a theme: one hand-written block of CSS under [data-theme="<name>"], about fifty declarations carrying twenty-one colours, the shape knobs and the font stacks — and roughly thirty decisions, because the theme generator fills in the ink that follows from a fill. Adding a client is adding a file.

What a user chooses is which theme they wear on each side, plus four axes layered over it: how round, how dense, which typefaces, light or dark. All of them are data-* attributes on <html> whose values live in static CSS.

A theme is one MODE, and everything else follows from that

Light and dark are two themes, not two blocks of one document — daisyUI's arrangement, adopted after measurement. It is what lets a tint be written as a percentage (bg-destructive/7) without landing on a different step in each mode, it is what makes a scoped preview a plain div, and it is why "which theme" is stored per side: a theme is a side, so the question without the side does not name a preference. The argument and the numbers are in a theme is one flat block.

The rule about colour is not "the user touches no colour" — appearance is colour, and always was. It is: the user never authors a colour value; they choose among themes someone already wrote.

preferencewhat changesattribute
themeByAppearancethe whole theme worn on that sidedata-theme
appearancewhich side is wornnone: it writes .dark

Publish one theme and nothing appears — Preferences.Colour hides itself below two choices. A client wanting a single immutable brand does nothing to get one.

The twenty-one, and the uses

A theme authors twenty-one colours. Every other name in the vocabulary is a use of one of them, declared once in tokens.css through @theme inline and never re-declared.

  • Surfaces and ink--background, --foreground, --card, --muted, --muted-foreground
  • Brand--primary, --secondary, --accent, each with its -foreground
  • Status--destructive, --info, --success, --warning, each with its -content
  • Lines--border, --ring

--popover, --field, --faint, --input, the eight sidebar names and the status -foregrounds are uses. A theme overrides one only when it genuinely wants it different — and it is worth knowing which actually do, because "is a use" and "is left alone" are different claims:

The counts below are measured over the twenty-nine files, and they split by origin: sixteen are hand-written here (49–50 declarations each) and thirteen were imported from daisyUI (33).

namewhat the shipped themes actually do
--sidebar --sidebar-foreground --accent-foregroundnone of the twenty-nine authors one. Each was the same value as another token in every single file, so the declaration said nothing and came out. --accent-foreground joined them when --accent stopped being imported as a brand fill: an on-fill ink is half a pair, and its other half is a surface now.
--secondary-foregroundnone of the sixteen; all thirteen imported ones. Here it was --foreground every time, so it came out. daisyUI authors an on-fill ink for secondary and it is not the page ink, so an imported theme brings a real value and says so.
--popoverthe eight hand-written dark themes and no light one — an overlay lifts off the card on the dark side and is the card on the light one. An imported theme leaves it to the chain.
--field --faint --inputthe sixteen author all three; the thirteen author none. Genuine values, not repetitions — daisyUI simply has no name for them.
the four status -foregroundsall twenty-nine. The imported ones do not bring them, so the importer computes them with pageInk — without that they fell through the bridge to the fill at full strength, which is 1.23:1 in one case.

The second row is the shape of the rule working, and it is the interesting one: the same name is a repetition in one theme and a real value in another, so what earns a declaration is measured per file rather than decided once for the vocabulary.

What the generator edits, and the three names people ask about

/theme-generator carries twenty-five pickers: the twenty-one, plus the four status -foregrounds that every one of the twenty-nine authors. The specimen pane on the right is sized to those — a group that no control moves would be decoration. Three names sit outside that set and get asked about often enough to settle here, with the counts that settle them:

namecall sites in packages/*/srcthemes authoring itverdict
--sidebarbg-sidebar on five recipes in sidebar.tsxnone of the twenty-nineneither a picker nor a specimen, and that is the answer.
--faintfifteen: the placeholder ink of eight field recipes, the ghost text in CompleteGhost, and five inks in CodeEditorsixteen, and the value differs from --muted-foreground in fourteen of thema real tier. It has no specimen, and that is a gap.
--inputtwenty: border-input on sixteen of them, plus the switch track, the scroll-area thumb and an unfilled starsixteen, and the value differs from --border in all sixteenalready drawn, on every field in the pane. A field border is not an indirect showing of --input; it is what --input is.

--sidebar is the case worth stating plainly, because "a token nobody draws" reads like an oversight and is not one. No theme authors it, so its value is decided entirely by the chain — --sidebar--popover--card — and both of those already have representation: --card has a picker, --popover has a specimen of its own. A bg-sidebar swatch would repaint one of them at a different label, which is the second spelling the rule at the top of this page exists to refuse. A picker would be worse: it would let a generated theme author a name that sixteen hand-written themes and thirteen imported ones all declined to. Nothing is missing here. Do not add one.

--faint is the opposite result from the same measurement. Fourteen of the sixteen give it a value its fallback does not have — it is the tier quieter than --muted-foreground, and it is what every placeholder and every code comment in the library reads at. Nothing in the specimen pane renders it today, for a small reason: every field in the Fields group carries a defaultValue, so no placeholder ever appears.

A block copied out of the generator is not yet a hand-written theme

The generator serialises exactly what it edits, so what you paste into packages/theme/themes/ authors twenty-five names and takes --popover, --field, --input and --faint off the chain. The sixteen hand-written themes all author those four with genuine values, so a theme that skips them draws its field borders at the page's border weight and its placeholders at --muted-foreground. Type them in after you paste; that is the difference between the imported shape (thirty-three declarations) and the shape this repository writes (forty-nine to fifty).

Why a use is bridged and never declared in :root

:root { --sidebar-primary: var(--primary) } looks equivalent to bridging it and is not. A custom property inherits its computed value, so it resolves once on <html> — and a scoped <div data-theme="dracula"> then inherits the already-substituted answer, painting Dracula's brand with the page's sidebar. Silently: nothing about it is a compile error. @theme inline puts the value in the utility, so the var() resolves on the element wearing the class.

The shape knobs

They are what let a client look different without looking like a fork, and none of them existed before: only the radius did, as a single knob for everything.

--noise is the newest and the one that shows the pattern most plainly. It multiplies the size of a grain layer the recipes already paint:

background-image: var(--fx-noise);
background-size: calc(var(--noise, 0) * 100%);

At 0 the layer is sized to nothing and never drawn; at 1 it tiles. One rule, no variant, no conditional — which is what makes texture a value a tenant sets rather than a look somebody has to write, exactly as --depth does for relief. The grain itself is a constant in the library's sheet: a theme says how much it wants, never what it is.

No shipped theme declares it, so var(--noise, 0) is where "off" lives. A theme that wants texture writes the line; one that does not says nothing, which is the same rule the colour names follow.

A knob nothing reads is worse than a missing one

--size-field and --size-selector were declarations no recipe read: a tenant set them, nothing moved, and the theme looked broken rather than ignored. --noise was a stranger version — named in the vocabulary and referenced nowhere at all. knobs-are-read.test.ts now fails for any knob that reaches neither a var() in the sheet nor a utility the library uses.

knobgoverns
--radius-box / --radius-field / --radius-selectorcard and dialog / button, input and tab / checkbox and badge
--size-field / --size-selectorthe unit a control's height is a multiple of
--strokeline weight
--depthrelief, 01, multiplied into the shadow and the fill's edge

--depth is a number, not a branch: the recipes already contain calc(var(--depth) * 30%), so 0 collapses the shadow to nothing and 1 gives it relief, from one stylesheet with no conditional in it.

The user axes

AxisAttributeSets
radiusdata-radius--radius-box / --radius-field / --radius-selector; the size-named rounded-* scale hangs off the field one
fontdata-font--font-sans
monoFontdata-mono-font--font-mono
densitydata-font-sizethe root font-size, which every rem in the system scales from

Every one of them is declared, not hand-wired: CORE_PREFS is generated beside the CSS by packages/theme/scripts/gen-theme.mjs, so the options a control offers are the table the selectors were emitted from. AXES is a projection of it. Adding a font is one line in the generator.

import { CORE_PREFS, prefOptions } from "@kanzo-tech/theme";

CORE_PREFS.density.attr; // "data-font-size"
prefOptions(CORE_PREFS.density); // [{ value: "default", label: "Default" }, …]
CORE_PREFS.themeByAppearance.options; // { from: "themes" } — the list is the TENANT's

A tenant may pin, withhold or restart any of them through one policy prop, under the namespace theme; see contributing a section, which is the same mechanism.

Appearance is the sixth preference, and it writes a class, not an attribute: .dark on <html>. It selects which SIDE is worn, and the theme axis is keyed by that side — so the two compose rather than competing, and neither is a hue somebody authored.

They must be on <html>, not a wrapper

Ark's overlays — Dialog, Popover, Menu, Select, Tooltip, Toast, HoverCard, Command — portal into document.body, outside any element you render. Tokens set on a wrapper never reach them. Density is stricter still: it sets the root font-size, and every size in the system is rem.

This is why there is exactly one provider, and why it writes to the document rather than rendering an element. A wrapper that set the same attributes on a <div> could not theme a single overlay, and its density axis would do nothing at all.

Setting them

KanzoThemeProvider owns the preferences and writes the attributes:

import { KanzoThemeProvider } from "@kanzo-tech/ui";

<KanzoThemeProvider defaults={{ radius: "md", density: "compact" }}>{children}</KanzoThemeProvider>

Read or change them at runtime with useKanzoTheme():

const { radius, set } = useKanzoTheme();
set({ radius: "none" }); // square corners everywhere

Or drop in the ready-made panel:

import { Preferences } from "@kanzo-tech/ui";

<Preferences />

Preferences takes an optional hotkey. It has no default on purpose — a design system should not claim an unmodified key in its host's global keymap without being asked.

Dark mode

.dark on <html> is the only mode selector, and KanzoThemeProvider writes it. Hand it to a host theme manager if you already run one — the shape matches next-themes:

import { useTheme } from "next-themes";

const { resolvedTheme, setTheme } = useTheme();
<KanzoThemeProvider appearance={{ resolvedTheme, setTheme }}>{children}</KanzoThemeProvider>;

Omit the prop and the provider's built-in fallback toggles .dark itself, which is what these docs do.

The preference is "light" | "dark" | "", where "" means the OS decides — there is no "system": the value with no value is how the token-layer references model it (daisyUI's --prefersdark, color-scheme: light dark) rather than the theme-switcher libraries, which make it a third string. A host next-themes speaks "system"; the provider translates it to "" on the way in and back on the way out, in one place, so the rest of the library never sees it.

"" is the same value the theme axis stores for "defer to the tenant", and that is not a coincidence: unset is one spelling here, which is what lets appearance be declared — and therefore resolved, pinned and offered — like every other axis.

Why a class and not light-dark()

Two reasons, both measured. About 150 dark: variants need a class to key off; and light-dark() makes per-mode values unreadable to getComputedStyle, which is exactly how the charts layer resolves var(--chart-N) for Observable Plot. Each theme file carries its own color-scheme. Never write documentElement.style.colorScheme — an inline declaration outranks every rule, permanently.

Tokens

Two families, two different rules — this trips people up, so it is worth stating plainly.

Neutral and brand: the base name is the FILL, and -foreground is the text that sits ON that fill. bg-primary text-primary-foreground is a readable pair.

Status (destructive / info / success / warning): three tokens, not two, and the extra one is the point. -foreground is a readable-on-background variant of the same hue (step 11) — for error text, invalid rings and destructive menu items. That it is not readable on the fill is not a contrast bug; it is Shark's convention, adopted verbatim.

So on-fill text gets its own token, -content — daisyUI's word for exactly this role, adopted because it already existed and was right. It is authored: nothing derives it at runtime, and what checks it is a guard over the shipped themes. What does propose one is the studio, at authoring time — pick a fill and both of its inks are filled in, -content by daisyUI's own rule and -foreground by mixing the fill 60% toward --foreground. Both are proposals you overwrite, and what ships is the literal in the file. bg-destructive text-destructive-content is the readable pair. A literal text-white is not: measured, that white sits at 2.13:1 on the warning fill and 3.81 on destructive. An exception to "token-backed utilities only" is where a defect hides — with no token there is nothing to measure, and with nothing to measure there is no failing test.

Theming a region, not the document

[data-theme="nord"] is an ordinary attribute selector. It matches an element, not a document, so the theme's custom properties land wherever you put the attribute and inherit into everything inside:

<div data-theme="nord">
  <Panel />
</div>

That is the whole mechanism, and it is what makes a tenant-branded panel inside an otherwise neutral application possible without an iframe, a second stylesheet or any JavaScript. The theme catalogue is twenty-nine of them on one page, each drawn wearing the same screen.

It works because of the bridge above, and it is the first thing a :root declaration would break: a custom property inherits its computed value, so a colour declared on :root resolves once on <html> and every region would paint the same theme.

Which is a rule with a scar on it

kanzo.css opened with a bare :root, [data-theme="kanzo"] for a day. :root has the same specificity as [data-theme="x"], so the tie broke on source order — and the imports are alphabetical, so every theme sorting before kanzo.css was silently repainted in kanzo's colours. Eleven of the sixteen there were then, dark ones included, color-scheme and all. The default theme now binds as :root:not([data-theme]), and two assertions in themes.test.ts hold it there.

Set `color-scheme` alongside it

The attribute carries the colours; it does not tell the browser which way the region leans, so form controls, scrollbars and the caret keep the document's. A themed region wants style={{ colorScheme: "dark" }} beside the attribute when the theme it wears is a dark one.

Two themers, and only one of them owns the root

KanzoThemeProvider writes the axes to document.documentElement and is the app's themer. KanzoTheme writes the same attributes to a <div> and is for previews — a gallery of five themes on one page is a real screen, and the root-only provider cannot express it. Both ship, they are not interchangeable, and the difference is asserted rather than described.

Never reach for KanzoTheme as the app's provider. Ark's overlays — Dialog, Popover, Menu, Select, Tooltip, Toast, HoverCard, Command — portal into document.body, outside any wrapper, so a wrapper cannot theme them; and density sets the root font-size that the whole rem scale resolves against, so on a wrapper it is broken outright. There was once only one provider for exactly that reason, and the one that wrote to a wrapper was deleted with no alias — it had been the documented entry point while a repo-wide grep found zero usages of it.

What would reverse it: a scope that could theme Ark's portalled overlays. It cannot, and that is a fact about the portal target rather than a preference — which is why the root provider stays the app's.

Held by packages/ui/src/index.test.ts, "exposes two themers, and they are not interchangeable"; packages/ui/src/theme/KanzoTheme.tsx.

What @kanzo-tech/theme exports besides CSS

The stylesheet is the product; these are the shapes a host needs when it builds its own control, its own storage, or its own section. None of them is required to use a theme.

The tables, generated. themeData is the four non-colour axes as a JS module — the same tables the generator writes, so no second list can drift from them — and ThemeData is its type. ThemeIndexEntry is one theme as a control sees it. The axis keys are KanzoThemeName, KanzoDensity, KanzoFont, KanzoMonoFont and KanzoRadius; all of them are host-extensible, and "" on a theme name means defer to the tenant's default rather than no theme.

Storage. STORAGE_KEY is where a preference document is kept, and CORE_NAMESPACE is the namespace the core's own preferences answer to inside a tenant's policy — the core is a section like any other, which is what that constant is for.

Preferences. CorePrefDecl and CorePrefKey are the core's axes declared in the shape any package contributes. A choice offers PrefOptions — value is stored, label is read — as a PrefOptions list or, when an author cannot list them, a PrefSource naming who owns the list. resolvePref walks one chain, most specific first, and the PrefOrigin it returns is what lets a panel say where a value came from and a test assert on it.

Sections. A LookDocument is a stored appearance, and its sections are opaque: the core never parses a section's payload. sectionOf reads one — a section you do not have answers an empty record — and withSection writes one back, preserving every other section byte for byte. A section declares SectionPrefDecls for the person on the screen and SectionTokenDecls for the document; a tenant answers with a SectionPolicy of SectionPrefPolicy, which is selection, never authorship. resolveSectionToken resolves a dotted token against a document and then the manifest's default, fallbackChain is that chain most specific first, and a Problem is a token the manifest does not declare or one that would silently fall back to a parent.

The density obligations. DENSITY_OBLIGATIONS is what the density axis owes, checkDensity grades it, and DensityObligation and DensityCheck are the two shapes involved. Density multiplies the root font-size the whole rem scale resolves against, so it is a multiplier on a bar both layers can break — which is why it is graded here and why a specific failure may still belong to the component that declared the row.

Authoring colour. inkFor returns the ink that belongs on a fill, as a hex ready to be written into a theme, and Oklch is a colour in OKLCh with l from 0 to 1 and h in degrees. This is the only derivation in the package and it runs in a form while somebody picks a colour, never in a page painting one.

Generated output

themes.css and theme-data.json are generated by scripts/gen-theme.mjs — the catalogue of @imports, the user's non-colour preferences, and the table of what each name defers to, read out of tokens.css so nothing restates it. An authoring tool that shows you what a theme looks like has to follow the same chain the cascade follows, and that table is how it does it without keeping a second copy of the bridge. Edit the generator, not those two; CI regenerates and fails on any diff.

tokens.css and themes/*.css are NOT generated. They are hand-written source, and a guard that regenerated them would have nothing to regenerate them from. That is the whole shape of the change: colour stopped being output.

On this page