Kanzo UI
Getting started

Onboarding a tenant

Write a theme, ship the file, let an attribute choose — the work you do once per client.

There are two onboarding pipelines and they are easy to confuse, so this page is only the second one.

Installation is per product, once: the packages, the stylesheet, the provider, the pre-paint script. This is per client, once: you write their theme, it ships as a file, and every surface the client ever sees is a reading of it.

This page used to describe a derivation, and there is no derivation

Until 2026-08-21 a tenant handed over two colours and a thirteen-stage pipeline produced a measured document of 199 custom properties. That pipeline is deleted. A theme is now a block of CSS somebody writes — see a theme is one flat block for the measurement that authorised the cut. What you gain is that a theme is legible, pasteable and diffable. What you take on is contrast: nothing derives an ink for you. The last section says what still checks you.

Write the theme

One file, one block, one mode. Copy the nearest shipped theme out of @kanzo-tech/theme/themes/ and change the colours.

themes/acme.css
[data-theme="acme"] {
  color-scheme: light;

  /* The twenty-one. Everything else in the vocabulary is a USE of one of these. */
  --background: #fbfcfd;   --foreground: #10151c;
  --card: #ffffff;         --muted: #eef1f5;
  --muted-foreground: #4a5464;
  --primary: #1f6feb;      --primary-foreground: #ffffff;
  --secondary: #e3e8ef;    --secondary-foreground: #10151c;
  --accent: #d6dde7;       --accent-foreground: #10151c;
  --destructive: #d61f27;  --destructive-content: #fff8f7;
  --info: #1155cc;         --info-content: #f7fafe;
  --success: #08794f;      --success-content: #f4fbf8;
  --warning: #b45c00;      --warning-content: #fffaf5;
  --border: #c2c9d4;       --ring: #1f6feb;

  /* Shape. These are what let a client look different without looking like a fork. */
  --radius-box: 0.75rem;   --radius-field: 0.5rem;  --radius-selector: 0.25rem;
  --size-field: 0.25rem;   --size-selector: 0.25rem;
  --stroke: 1px;           --depth: 0;

  /* Optional. Omit them and the theme inherits; a theme that DECLINES the categorical
     channel says so, which is what a monochrome theme is. */
  --chart-1: #1f6feb;      /* … through --chart-8 */
}

Dark is a second theme, not a second block:

themes/acme-dark.css
[data-theme="acme-dark"] {
  color-scheme: dark;
  /* …the same twenty-one, chosen for a dark ground. */
}

Twenty-one carry a value; nothing else does

Do not write the card ink, the sidebar brand or the other names bridged in tokens.css. They are uses of the twenty-one and are declared once, centrally — writing them again in a theme is the duplication the whole layer exists to prevent. Override one only when this theme genuinely wants it different, which is a decision, not a default.

Worth knowing from the sixteen themes written here: not one of them ends up needing --secondary-foreground or --accent-foreground. Both were the same value as --foreground in every single case, so both came out. They are above because the vocabulary allows them, not because a theme is expected to fill them in — and the same is true of --popover, which only the dark ones turned out to want. The thirteen imported from daisyUI do author that pair, which is the rule working rather than an exception to it: over there those two fills carry an on-fill ink of their own.

Ship the file

Add it to the catalogue and regenerate:

pnpm --filter @kanzo-tech/theme gen

That reads the themes/ directory, writes the @import list into themes.css and records each theme's own color-scheme in theme-data.json. Adding a theme is adding a file — nothing lists them twice.

Decide what this client's users may change

A tenant policy is keyed by namespace, and the core answers to theme like any other section. Pin what the client insists on and withhold the control:

<KanzoThemeProvider
  policy={{ theme: { themeByAppearance: { pinned: "acme" }, radius: { pinned: "sm" } } }}
  themes={themeIndex}
>

A pinned theme leaves one to offer, so the colour section hides itself. That is the white-label case working, not a control failing.

Offer the choice, if there is one

import { themeIndex } from "@kanzo-tech/theme";

<KanzoThemeProvider themes={themeIndex} onThemeRetired={(name) => notify(name)}>

The panel draws two grids — the theme worn on the light side and the theme worn on the dark one — because a theme is a side. Each cell paints itself by setting data-theme on a div, so the preview is the real thing rather than a strip of swatches.

Verify

Contrast is yours now, and one guard still checks the artefact. status.test.ts reads every shipped theme and measures each status fill against the ink meant to sit on it, in all of them. It does not check every pair in the vocabulary — nothing does — so the ones to read yourself are:

  • --foreground on --background, and on --card and --muted (4.5:1 for body text);
  • each --*-content on its own fill, which is what the guard covers;
  • --border against --background, which WCAG 1.4.11 asks 3:1 of where it identifies a control.

Then look at it. Set data-theme on <html> in the docs and read a real screen.

On this page