Kanzo UI
Layout

Preferences

Every preference a person may change — the theme for each side, the side, density, and what installed packages contribute — and nothing a theme owns.

Open full size

The panel opens on arrival so there is something to look at. Toggle the trigger in the corner to see the rest of it: the drawer is non-modal, so the form behind it stays usable while the theme changes live.

What is a preference, and what is not

A preference answers how this person reads: the side they wear, the theme they wear on each side, and how big everything is. Corner radius and typography answer what the product looks like, so they belong to the theme and have no control here. A tenant that wants square corners or its own face publishes a theme. The rule and the reasons are on Preference or theme.

Motion and contrast are the platform's questions. The library honours prefers-reduced-motion, and a tenant that wants more contrast than AA publishes a high-contrast theme for people to pick.

One preference, four places it appears

A preference is declared once, where its values live. For the core that is the generator that emits the CSS. Everything else follows from the declaration:

Declarationkind, options (or the name of whoever owns them), default, attribute, doc. CORE_PREFS for the core's three, a SectionManifest for an optional package's.
Resolutionone chain, most specific first: what a tenant pinned, what the person stored, where the tenant said to start, the declaration's default. useKanzoTheme().corePrefs / .sectionPrefs carry the answer, via says which link gave it, and offered says whether to draw a control at all.
Renderer<Pref>, one Ark control per kind: a row of radio cards for a list the author wrote, a Select for a list the data answers, a Switch, a Slider. A surface never re-decides what a range looks like. What the declaration cannot know — the lists it names, the pictures on its cards — its owner answers through a SectionProvider.
Surfacesthe drawer below, a settings page, a dock inside a canvas. Each composes <Pref>s, or PreferencesSections for a whole section.

Usage

import { PreferencesPanel, PreferencesRoot, PreferencesTrigger } from "@kanzo-tech/ui";
<PreferencesRoot hotkey="t">
  <PreferencesTrigger />
  <PreferencesPanel />
</PreferencesRoot>

The root holds the drawer's state and the hotkey, the trigger is the floating button in the corner, and the panel is the drawer. There is no all-in-one: it was this arrangement under a fourth name, and the arrangement is three lines a host writes and rearranges. The panel writes through useKanzoTheme, which sets attributes on <html>. That is why every component re-skins, including Ark's portaled overlays, which render into document.body and would miss a wrapper element.

The panel is deliberately non-modal: you keep using the app while it re-skins live behind the drawer, which is the only way to judge a theme. It still closes on an outside click. Every change applies on selection, not on close, and the hotkey reopens it where you left off.

There is no default hotkey. hotkey is a bare key ("t") or a chord ("mod+," for ⌘, / Ctrl+,) — the grammar CommandDialog's hotkey takes — registered on window and ignored while you are typing in a field, and a design system must not claim one in its host's keymap without being asked.

The sections

PreferencesSections draws every preference this host offers, in this order:

  1. The theme. A light card and a dark card, described below. It is the control for both appearance and themeByAppearance.
  2. Density. Compact, default and cozy, each a percentage of the browser's own font size.
  3. What installed packages contribute. For example, the graph contributes its picture, where its points come from and its forces. See Sections.

It is a composition of the part, <Pref>, and not a second way in. A page that wants one preference, or its own order, writes the parts:

<PreferencesSections />                    // everything, as the panel draws it
<PreferencesSections namespace="theme" />  // the core's alone: the theme, then density
<PreferencesSections namespace="graph" />  // one package's
<Pref name="theme.density" />              // one preference, where the page puts it

A name is the section's namespace and the preference's key; the core's namespace is theme, and theme.appearance and theme.themeByAppearance both name the theme cards, because the cards are one control for the two of them. A name nothing declares draws nothing. A part draws nothing for a choice the tenant pinned or withheld, for a preference whose when does not hold, or for a list nobody answered.

A control of your own reads the same state. usePref(name) answers what <Pref> draws — value, setValue, options, offered and shown — so a product can draw a preference its own way without the library growing an arm:

const density = usePref("theme.density");
if (density?.offered) density.setValue("compact");

Names are checked once the app says what it registered — TanStack's Register, augmented once beside the provider:

const SECTIONS = [GRAPH_SECTION];

declare module "@kanzo-tech/ui" {
  interface Register {
    sections: typeof SECTIONS;
  }
}

From then on name completes, a typo fails tsc, and usePref("graph.placement").value is "force" | "map" | "clustered".

Theme
Appearance

Worn while the appearance is light.

Kanzo

Worn while the appearance is dark.

Kanzo Dark

Density

The theme cards

A theme is one mode, so a person does not choose "a theme". They choose one for light and one for dark, and which side to wear. The cards draw exactly that, and the card is the selector:

  • Light theme and Dark theme are two cards, side by side, and together they form one radio group, Appearance. Clicking a card anywhere but on a swatch wears its side: it writes appearance, and with it .dark.
  • Active marks the worn card, which is the checked one: a primary border and the badge in its header.
  • Swatches. Each card offers only its own side's themes, as a row of round swatches. Pointing at one (or focusing it) previews it in that card's ThemePreview and name, and nowhere else. The page does not change, and moving away puts the card's own theme back. Clicking a swatch commits it: the theme is filed under the card's side and that side is worn.

While nothing is stored, the side is the OS's prefers-color-scheme. Once the person picks a side, the pick is stored and wins. A tenant's policy.theme.appearance.default outranks the OS.

Each side remembers its own theme (themeByAppearance), so wearing the other card brings back what was chosen for it. With no choice stored, each card shows the default pair. The swatch row offers exactly what the provider's themes lists, so narrowing the offer is a prop on the provider.

What the tenant's policy draws

policy.themeWhat draws
nothingboth cards, as the Appearance choice, each with its swatches
appearance: { pinned: "dark" } or { hidden: true }only the worn side's card, with its swatches and no radio
appearance: { default: "dark" }both cards, the dark one checked while nothing is stored
themeByAppearance: { hidden: true } or { pinned }both cards without swatches: the tenant's default pair, previewed. Choosing a card still picks the side
both withheldno theme section; density and the rest still draw
density: { pinned } or { hidden: true }ignored. Density is always the person's; only { default } applies

The retired-theme notice

When the stored theme is no longer in themes, because a tenant dropped a family or the library retired the theme, the provider clears the choice and the cards show an info alert: "Brand updated — “dracula” is no longer offered here. You are seeing the default." Translate it through translations.retiredTitle and translations.retired(theme). A host that also wants it said as a toast reads the same state, useKanzoTheme().retiredTheme — Retired themes has the composition.

Accessibility

  • The two cards are an Ark radio group named Appearance (translations.appearance). Each card's radio is its header, stretched over the card, so the whole card is the click target while no control is nested in another.
  • Each card's swatches are a radio group named by the card's title, with manual activation: Tab enters the row on the chosen swatch, arrow keys (and Home / End) move focus and preview, Enter or Space chooses, and Escape puts the preview back. Choosing on every arrow would repaint the page on every key press.
  • A swatch's accessible name is the theme's label, also shown as its tooltip. The swatch circles and the preview are decorative (aria-hidden).
  • The cards sit on a container query, not a viewport one: side by side from a 36rem container, stacked in a 384px drawer, with nothing passed down.

Translating

Every word the panel and the core's sections author is an entry of translations, the one prop every component here takes its words through. Give it once, to PreferencesRoot, and every part inside reads it; a part's own translations win for that part. A theme's own label was written by the tenant and passes through verbatim.

Tema
Apariencia

Se usa con la apariencia clara.

Kanzo

Se usa con la apariencia oscura.

Kanzo Dark

Choosing your own set

PreferencesPanel renders PreferencesSections when given no children. Pass children to compose your own, and the pinned Reset · Done footer stays. A product can lead with its own preferences and then place the library's:

Open full size
<PreferencesRoot hotkey="t">
  <PreferencesTrigger />
  <PreferencesPanel>
    {/* the product's own section, on top */}
    <PreferencesField label="Sidebar side">
      <SegmentGroup value={side} onValueChange={(d) => d.value && setSide(d.value)}>
        <SegmentGroupItem value="start">
          <SegmentGroupItemText>Start</SegmentGroupItemText>
        </SegmentGroupItem>
        <SegmentGroupItem value="end">
          <SegmentGroupItemText>End</SegmentGroupItemText>
        </SegmentGroupItem>
      </SegmentGroup>
    </PreferencesField>
    {/* every preference the library offers, below the product's */}
    <PreferencesSections />
  </PreferencesPanel>
</PreferencesRoot>

children replaces the default body, so a composed panel must place PreferencesSections or it drops every preference.

PreferencesField titles a single control; PreferencesFieldSet titles a group of them. That split is about semantics, not styling. Field addresses one control by id, and a radio group has one hidden input per item, so there is no id for its label to point at. FieldSet + FieldLegend is the answer, and Ark's useRadioGroup reads the legend's id straight out of the fieldset context as the group's accessible name.

The other surface: a settings page

The same sections live inline on a product's own settings page, with no drawer anywhere. There is no page component to import, because a settings page is your layout and your navigation with the sections dropped in:

<ShellMain>
  <SectionRoot className="mx-auto w-full max-w-5xl px-6 py-8">
    <div className="grid gap-8 md:grid-cols-[13rem_1fr]">
      <nav aria-label="Settings">{/* your own menu — ItemGroup / Item */}</nav>
      <div className="flex min-w-0 flex-col gap-8">
        <PreferencesSections />
      </div>
    </div>
  </SectionRoot>
</ShellMain>

The settings showcase is that page finished. A section is never separated from the next by a rule, in either surface: the heading is the separation.

ThemePreview

kanzo
kanzo-dark
catppuccin-latte
catppuccin-mocha
lofi
lofi-dark
nord
nord-dark
import { ThemePreview } from "@kanzo-tech/ui";

<ThemePreview theme="nord-dark" />

A miniature app screen — a header with the brand mark and a primary button, a sidebar with its active row, both inks, and two lines of code in the --syntax-* colours — rendered inside a region wearing the theme, so every part reads the theme's real tokens rather than a copy of its colours. The theme cards, the themes catalogue and the generator all draw it. It is decorative (aria-hidden): whatever offers the theme names it.

API Reference

PreferencesRoot

PropTypeDefault
childrenReactNode—
hotkeystring—
defaultOpenbooleanfalse
translationsPartial<PreferencesTranslations> — read by every part insideEnglish, below

PreferencesTrigger

PropTypeDefault
classNamestring—
translationsPartial<Pick<PreferencesTranslations, "open">>the root's

PreferencesPanel

PropTypeDefault
childrenReactNode<PreferencesSections />
translationsPartial<PreferencesTranslations>the root's

PreferencesSections

PropTypeDefault
namespacestringevery namespace, the core's ("theme") first
translationsPartial<PreferencesTranslations>the root's

Pref

PropTypeDefault
namePrefName — "<namespace>.<key>", checked once Register is augmentedrequired
translationsPartial<PreferencesTranslations> — the theme cards' wordsthe root's

usePref

usePref(name: PrefName): PrefState | null — null for a name nothing declares.

FieldType
namestring
declSectionPrefDecl
value / setValuePrefValue<name> — the options' union for a listed choice, a string otherwise
optionsreadonly PrefOption[] | null — null for a toggle or range, and for a list nobody answered
offeredboolean — false where a tenant pinned or withheld it
shownboolean — whether its when holds
specimenPrefSpecimen | undefined — the owner's picture for an option

SectionProvider

What only a section's owner knows, for the subtree beneath it. Merges over the one above.

PropTypeDefault
namespacestringrequired
sourcesRecord<string, readonly PrefOption[]> — the lists the section's choices name with { from }—
specimensRecord<string, PrefSpecimen> — a picture per option, keyed by preference—

PreferencesTranslations

KeyDefault
title"Preferences" — the panel's title
hint"Applied live · saved to this browser." — the line under it
open"Theme preferences" — the trigger's accessible name
close"Close preferences" — the close button's accessible name
reset"Reset"
done"Done"
none"None" — a sourced choice with nothing chosen
theme"Theme" — the heading over the cards
appearance"Appearance" — the accessible name of the two cards' radio group
day"Light theme"
dayDescription"Worn while the appearance is light."
night"Dark theme"
nightDescription"Worn while the appearance is dark."
active"Active"
retiredTitle"Brand updated" — the retired-theme notice's heading
retired(theme) => string — its body, given the retired theme's name

Density's label and its options come from the declaration. A contributed section's words are its manifest's.

Containers

For sections of your own, so a product's controls are indistinguishable from the library's.

Flat exportPropsFor
PreferencesFieldlabel, childrenone control (Field + FieldLabel)
PreferencesFieldSetlabel, childrena group of controls (FieldSet + FieldLegend)

ThemePreview

ThemePreviewProps extends a div's props.

PropTypeDefault
themestringinherits the surrounding cascade
appearance"light" | "dark"the theme's own side
classNamestring—

There is no Preferences.X namespace: statics built with Object.assign do not survive the RSC boundary, so the flat name is the only name. Every part's props are exported as an interface — PreferencesRootProps, PreferencesTriggerProps, PreferencesPanelProps, PreferencesSectionsProps, PrefProps, SectionProviderProps and PreferencesTranslations — with PrefName, PrefValue, PrefState, PrefSpecimen and the Register interface an app augments.

On this page