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.
| preference | what changes | attribute |
|---|---|---|
themeByAppearance | the whole theme worn on that side | data-theme |
appearance | which side is worn | none: 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).
| name | what the shipped themes actually do |
|---|---|
--sidebar --sidebar-foreground --accent-foreground | none 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-foreground | none 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. |
--popover | the 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 --input | the sixteen author all three; the thirteen author none. Genuine values, not repetitions — daisyUI simply has no name for them. |
the four status -foregrounds | all 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:
| name | call sites in packages/*/src | themes authoring it | verdict |
|---|---|---|---|
--sidebar | bg-sidebar on five recipes in sidebar.tsx | none of the twenty-nine | neither a picker nor a specimen, and that is the answer. |
--faint | fifteen: the placeholder ink of eight field recipes, the ghost text in CompleteGhost, and five inks in CodeEditor | sixteen, and the value differs from --muted-foreground in fourteen of them | a real tier. It has no specimen, and that is a gap. |
--input | twenty: border-input on sixteen of them, plus the switch track, the scroll-area thumb and an unfilled star | sixteen, and the value differs from --border in all sixteen | already 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.
| knob | governs |
|---|---|
--radius-box / --radius-field / --radius-selector | card and dialog / button, input and tab / checkbox and badge |
--size-field / --size-selector | the unit a control's height is a multiple of |
--stroke | line weight |
--depth | relief, 0–1, 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
| Axis | Attribute | Sets |
|---|---|---|
| radius | data-radius | --radius-box / --radius-field / --radius-selector; the size-named rounded-* scale hangs off the field one |
| font | data-font | --font-sans |
| monoFont | data-mono-font | --font-mono |
| density | data-font-size | the 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'sA 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 everywhereOr 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.