Preferences
The parts a preference menu is built from — the sections, a surface to put them on, and the containers for your own.
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.
One preference, four places it appears
A preference is declared once — where its values live, which for the core's axes is the generator that emits the CSS — and everything else follows from that declaration:
| Declaration | kind, options (or the name of whoever owns them), default, attribute, doc. CORE_PREFS for the library's axes, a SectionManifest for an optional package's. |
| Resolution | one chain, most specific first: what a tenant PINNED, what the user 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 | one control per kind — a radio list, a Switch, a Slider — so a surface never re-decides what a range looks like. The specimens are a lookup keyed by axis. |
| Surfaces | the drawer below, a settings page, a dock inside a canvas. All of them mount the same sections. |
Two controls are not the generic one, and both because the control differs rather than the data:
radius is a slider over an ordered scale, and PreferencesColor draws whole documents with a live
cascade preview.
Four abstractions, and a surface is not one of them
What this page documents is a kit for building a preference menu, and the drawer above is one assembly of it rather than the thing itself. A product with a couple of screens wants the floating drawer — metadata form opens it with a key and never leaves the page it was on. A product with a real settings area wants a page with a menu down the side — settings. Both are first-class, and neither is the default one. The same exports build both, and no part of either takes a prop naming the other.
| The parts | What they are |
|---|---|
The sections — PreferencesColor, PreferencesDensity, PreferencesRadius, PreferencesFont, PreferencesMonoFont | One preference each, wired to useKanzoTheme() directly. They take no surface, no layout prop and no context of yours: mount one in a drawer, a page, a tab or a dialog and it edits the same live theme. |
A surface — PreferencesRoot, PreferencesTrigger, PreferencesPanel (or the all-in-one Preferences) | The drawer, assembled: a non-modal dialog, an optional hotkey, a floating trigger and a pinned Reset · Done. It is the only surface the library ships, because it is the only one that is the same in every product. A settings page is layout — Sidebar, ShellMain, Section — and you already have those. |
The containers — PreferencesField, PreferencesFieldSet | Your own preference, titled the way the library's are, so a product's section and a library section are indistinguishable in either surface. |
The registry — PreferencesSections | Whatever the packages this host installed contribute, drawn in the same language. It is what stops an installed package from growing a settings surface beside yours. |
The one thing a section will not do is ask how big the window is. PreferencesColor draws its two
side cards under a container query, so it is one column in a 384px drawer and two in a settings
pane, with nothing passed down to say so — the question a section may ask is how much room it was
given. A layout prop would be the surface deciding for the section again, which is the whole thing
this split exists to prevent.
Usage
import { Preferences } from "@kanzo-tech/ui";<Preferences hotkey="t" />The all-in-one renders a floating trigger plus the drawer. It writes through useKanzoTheme,
which sets data-* 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, because that is what a panel is expected to do — every change applies on selection, not on close, and the hotkey reopens it where you left off.
There is no default hotkey. hotkey registers a bare, unmodified key on window (ignored
while you are typing in a field), and a design system must not claim one in its host's keymap
without being asked.
Colour is a preference — a hue is not
Six sections at most, two of them conditional. The rule is not "the user touches no colour", which
was never true: appearance is colour. It is that the user never authors a colour value —
they choose among colours someone already validated.
So PreferencesColor is here, and base, accent and chart scheme are not. Those let a user compose
part of a palette a hue at a time; a theme is written once and chosen whole. Authoring one is a
real surface, and it is a different one — see the theme
generator, which is where the "copy the CSS" half lives, aimed at
whoever writes a theme rather than at whoever wears it. The section hides itself below two
published themes, so a tenant shipping one sees the four axis sections and nothing else.
Appearance has no section, and no control of its own
appearance is a preference, but it is not a section. Pressing a side card in Colour wears
that side, and that is the whole appearance control: the card is already a full-size depiction
of what you would get, and a depiction you cannot enter is worse than a card plus a button
elsewhere saying the same thing.
An AppearanceToggle — a sun/moon button beside the close X — used to say it anyway. It is
deleted, not moved: it flipped light ⇄ dark and so does a card, and "follow the OS" was never
on it (that is "", and Reset is the way back).
What that costs is worth knowing before you rely on it: Colour hides itself below two published
choices, so a tenant shipping one theme now has no appearance control in this panel. Their users follow the OS, or the host mounts its own control against
useKanzoTheme().setAppearance.
There is no third state on that flip: following the OS is null, the absence of a pinned side, and
DEFAULT_PREFS.appearance is "" — so an app with nothing stored follows the OS until the first
click. Reset, below, is the way back: it unsets every preference, appearance included, and the
page lands on whatever the tenant's policy starts a user with.
Anatomy
Preferences.Root
├── Preferences.Trigger
└── Preferences.Panel
├── Preferences.Colour — only when the tenant published two choices
├── Preferences.Density
├── Preferences.Radius
├── Preferences.Font
└── Preferences.MonoFontPanel renders that whole set when given no children, plus a pinned Reset · Done footer that
stays whatever the children are. Pass children to choose your own set.
Colour comes first, and it is one section and one list. It used to be one section over a tree:
a palette contained identities, so an entry was a document or a brand inside one, and the panel
flattened two levels into composed entries like Bank · Retail — prefixed only when there was more
than one palette to disambiguate against.
A brand is a theme now, so there is no tree left to flatten. bank and bank-private are two
entries beside dracula, and the containment that used to guarantee a tenant's brands share a
neutral is the author's job instead: two themes share a neutral because whoever wrote them wrote the
same one.
It guards itself rather than being guarded at the call site — every section is exported flat for a host's own settings page, where a call-site guard would be invisible.
Each theme draws itself
There are two cards, one per side, and each is a tile you press: the tile wears that side, the
chips beneath choose which theme that side wears, and the pill in the corner says which side is
live. GitHub's Appearance page is the shape, minus its verbs — it has no Use button on a card
either, because a control the size of the thing it selects does not need one, and a settings panel
that keeps removing second controls should not have added one here.
The semantics are GitHub's now, and that is new. They pair a day theme with a night theme because their themes are single-mode; ours used to each carry both, so a card meant which document this user wears on the light side. A theme is one mode, so a card is the light theme.
An entry is a miniature of the interface, not a strip of hexes: a page, a brand fill, two lines
of ink and the categorical set. None of it is data. Every theme a tenant publishes is already in the
page under its own [data-theme], so the cell sets that attribute and the utilities inside it
resolve against that theme — bg-primary is that tenant's brand.
A strip of four role colours was what this did, and it could not depict a theme. On the default's
own, --primary and --foreground are the same value, so two of the four chips were one colour and
the control said nothing about what it was offering. The categorical strip draws all eight slots on
purpose: past a theme's --chart-capacity the author writes var(--muted-foreground) into the
remaining slots, so a set holding seven says so by going grey at the end.
Hovering previews, leaving puts it back
The reference behaviour is VS Code's and Zed's — a theme applies as you arrow through the list and
reverts when you leave — and it is affordable here because a theme is an attribute rather than a
stylesheet to fetch. Pointer or keyboard focus writes it on <html>; leaving restores the exact
attributes that were there on the way in, rather than recomputing what they should be.
The grid is the other half. Stacked at full width, the themes this site publishes took over half the panel's scroll height and pushed density, radius and both font axes below the fold.
What the installed packages add
The five sections above are the core's. A package a host installed may contribute its own choice — the graph contributes which look the canvas draws — and it appears in this panel, in this visual language, rather than in a settings surface of its own. That is the whole difference between one product and several sharing a window.
PreferencesSections draws all of them, in registration order, and draws nothing when the host
registered none. It is exported flat like every other section because children replaces the
canonical set: a host composing its own panel must place it, or every contributed choice silently
disappears.
<PreferencesPanel>
<PreferencesColor />
<PreferencesDensity />
<PreferencesSections />
</PreferencesPanel>A choice the tenant pinned or withheld is not drawn at all. How a package declares one, how the host registers it and how a tenant fixes it: Contributing a section.
Choosing the sections
Panel renders the canonical set when given no children. Pass children to choose your own —
and the pinned footer stays. A typography-only panel, for instance. Same real drawer, floating
over the page, in its own viewport.
<PreferencesRoot hotkey="t">
<PreferencesTrigger />
<PreferencesPanel title="Typography">
<PreferencesFont />
<PreferencesMonoFont />
</PreferencesPanel>
</PreferencesRoot>Use the flat parts (PreferencesFont), not the Preferences.Font statics. The namespace is
built with Object.assign, and statics are lost when a module becomes a client reference under
RSC — Preferences.Font reads back as undefined there. We hit this for real.
Extending the panel
The children are yours, so a product can lead with its own preferences and then fall back to
the library's axes — one drawer, product prefs and theme prefs together, all live. Here a
product Sidebar side control sits above the theme sections. This is the move the
metadata-form showcase uses for its Layout picker.
<PreferencesRoot hotkey="t">
<PreferencesTrigger />
<PreferencesPanel>
{/* the product's own section, on top — the extension point */}
<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>
{/* the library's own axes, flat, below the product's */}
<PreferencesRadius />
<PreferencesDensity />
</PreferencesPanel>
</PreferencesRoot>PreferencesField titles a single control; PreferencesFieldSet titles a group of them.
That split is a semantics call, not a styling one. 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
Every section is exported flat, so the same controls live inline on a product's own settings page with no drawer anywhere. This is the whole of it — 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">
<PreferencesColor />
<div className="grid gap-6 @container md:grid-cols-2">
<PreferencesDensity />
<PreferencesRadius />
<PreferencesFont />
<PreferencesMonoFont />
</div>
<PreferencesSections />
</div>
</div>
</SectionRoot>
</ShellMain>The settings showcase is that page finished — a product rail beside it,
a settings menu of ItemGroup / Item rows, and the identical sections. Three things it gets from
the split and does not have to arrange:
- The colour section becomes two columns on its own, under its container query. The page passes nothing.
- The side cards are the appearance control, so a page with no panel chrome still has one.
- Every heading is one spelling — small, upper, muted — because each section titles itself. Your
own sections get the same by going through
PreferencesField/PreferencesFieldSetrather than writing a heading beside a control.
A section is never separated from the next by a rule, in either surface. The heading is the separation; a line between two of them says one is a different kind of thing.
API Reference
Preferences
| Prop | Type | Default |
|---|---|---|
hotkey | string | — |
defaultOpen | boolean | false |
triggerClassName | string | — |
Preferences.Root
| Prop | Type | Default |
|---|---|---|
children | ReactNode | — |
hotkey | string | — |
defaultOpen | boolean | false |
Preferences.Panel
| Prop | Type | Default |
|---|---|---|
children | ReactNode | the canonical sections |
title | string | "Preferences" |
hint | string | "Applied live · saved to this browser." |
Preferences.Trigger
| Prop | Type | Default |
|---|---|---|
className | string | — |
Sections
Each reads and writes useKanzoTheme() directly. The four axis sections take no props; Colour takes
a label for its legend and two strings for the retirement notice, which are its only
library-authored copy — a theme's own name was written by the client and passes through verbatim.
Colour writes one value, keyed by the side it applies to. That sentence used to read "both
halves in one patch, because it is one choice", and separately was not a style preference: writing
the palette and the brand apart let the palette change file and restore a remembered brand over the
top of the one being clicked, so the memory won against the user. There is one field now, and the
memory it needed went with the containment.
| Export | Axis | Attribute |
|---|---|---|
PreferencesColor | themeByAppearance + appearance | data-theme; the side cards write appearance |
PreferencesDensity | density | data-font-size |
PreferencesRadius | radius | data-radius |
PreferencesFont | font | data-font |
PreferencesMonoFont | monoFont | data-mono-font |
There is no Preferences.Colour static to pair these with. The namespace was built with
Object.assign and does not survive the RSC boundary, so the flat name is the only name.
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) |
Every part's props are exported as an interface — PreferencesColorProps, PreferencesProps,
PreferencesRootProps and PreferencesSectionsProps — so a wrapper can take the same props without
restating them.