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.
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:
| Declaration | kind, 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. |
| Resolution | one 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. |
| Surfaces | the 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:
- The theme. A light card and a dark card, described below. It is the control for both
appearanceandthemeByAppearance. - Density. Compact, default and cozy, each a percentage of the browser's own font size.
- 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 itA 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".
Worn while the appearance is light.
Kanzo
Worn while the appearance is dark.
Kanzo Dark
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
ThemePreviewand 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.theme | What draws |
|---|---|
| nothing | both 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 withheld | no 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.
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:
<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
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
| Prop | Type | Default |
|---|---|---|
children | ReactNode | — |
hotkey | string | — |
defaultOpen | boolean | false |
translations | Partial<PreferencesTranslations> — read by every part inside | English, below |
PreferencesTrigger
| Prop | Type | Default |
|---|---|---|
className | string | — |
translations | Partial<Pick<PreferencesTranslations, "open">> | the root's |
PreferencesPanel
| Prop | Type | Default |
|---|---|---|
children | ReactNode | <PreferencesSections /> |
translations | Partial<PreferencesTranslations> | the root's |
PreferencesSections
| Prop | Type | Default |
|---|---|---|
namespace | string | every namespace, the core's ("theme") first |
translations | Partial<PreferencesTranslations> | the root's |
Pref
| Prop | Type | Default |
|---|---|---|
name | PrefName — "<namespace>.<key>", checked once Register is augmented | required |
translations | Partial<PreferencesTranslations> — the theme cards' words | the root's |
usePref
usePref(name: PrefName): PrefState | null — null for a name nothing declares.
| Field | Type |
|---|---|
name | string |
decl | SectionPrefDecl |
value / setValue | PrefValue<name> — the options' union for a listed choice, a string otherwise |
options | readonly PrefOption[] | null — null for a toggle or range, and for a list nobody answered |
offered | boolean — false where a tenant pinned or withheld it |
shown | boolean — whether its when holds |
specimen | PrefSpecimen | 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.
| Prop | Type | Default |
|---|---|---|
namespace | string | required |
sources | Record<string, readonly PrefOption[]> — the lists the section's choices name with { from } | — |
specimens | Record<string, PrefSpecimen> — a picture per option, keyed by preference | — |
PreferencesTranslations
| Key | Default |
|---|---|
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 export | Props | For |
|---|---|---|
PreferencesField | label, children | one control (Field + FieldLabel) |
PreferencesFieldSet | label, children | a group of controls (FieldSet + FieldLegend) |
ThemePreview
ThemePreviewProps extends a div's props.
| Prop | Type | Default |
|---|---|---|
theme | string | inherits the surrounding cascade |
appearance | "light" | "dark" | the theme's own side |
className | string | — |
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.