Theming
Theme the kit with --zg-* CSS variables derived from two colors.
Every color, shadow and radius the kit draws is a --zg-* CSS variable. The palette engine derives the full set from a seed (a canvas color and an accent) and checks text contrast. Each built-in theme also becomes a seed.
The desktop app's appearance settings write these tokens. The terminal app's themes are a separate system. The preview below scopes its own variables, so picking a theme there leaves the page's theme alone.
Tokens#
styles/tokens.css defines every variable with dark fallbacks.
| Group | Variables |
|---|---|
| Canvas and surfaces | --zg-bg (canvas), --zg-tray, --zg-raised, --zg-track, --zg-overlay, --zg-well, --zg-tray-ring, --zg-raised-edge, --zg-hairline, --zg-row-hover, --zg-row-active |
| Text | --zg-text, --zg-text-muted, --zg-text-subtle |
| Lines | --zg-border, --zg-border-strong, --zg-ring (focus) |
| Accent | --zg-accent, --zg-accent-hover, --zg-accent-soft, --zg-accent-soft-strong, --zg-accent-fg, --zg-accent-fill, --zg-accent-fill-hover, --zg-accent-fill-fg, --zg-accent-alt, --zg-model |
| Status | --zg-success, --zg-warn, --zg-danger, --zg-info, each with a -soft wash, and --zg-danger-fill, --zg-danger-fill-hover, --zg-danger-fill-fg |
| Code and diffs | --zg-code-bg, --zg-code-fg, --zg-selection, --zg-diff-add-bg, --zg-diff-remove-bg, --zg-diff-add-content-bg, --zg-diff-remove-content-bg, --zg-diff-add-fg, --zg-diff-remove-fg |
| Chips | --zg-chip-default-bg / -fg, --zg-chip-skills-bg / -fg, --zg-chip-files-bg / -fg |
| Depth | --zg-shadow-raised, --zg-shadow-live, --zg-shadow-inset, --zg-shadow-float, --zg-shadow-well, --zg-shadow-fill, --zg-fill-sheen, --zg-scrim |
| Contrast | --zg-contrast-base, --zg-contrast-boost, --zg-contrast-boost-border, --zg-contrast-target, --zg-depth-mix, --zg-depth-hairline-mix |
| Shape and type | --zg-radius-xs / -sm / -md / -lg / -xl (4, 6, 8, 12, 16px), --zg-font-ui, --zg-font-chat, --zg-font-prompt, --zg-font-mono, --zg-font-size-chat, --zg-font-size-prompt, --zg-font-size-code |
| Motion | --zg-motion-<name> (with -duration and -ease), --zg-ease-out, --zg-ease-in-out, --zg-ease-drawer, --zg-panel-ms |
Text, borders and surfaces are computed: a palette writes raw inputs (--zg-text-raw, --zg-tray-raw, ...) and the contrast layer mixes them. Do not read the -raw names. Avoid the legacy aliases --zg-surface-1 to -3, --zg-bg-elevated, --zg-bg-hover and --zg-error.
Mermaid diagrams in the transcript read these tokens through Pele's --pele-* variables, set on .zg-diagram. A custom theme styles them with no extra work (diagrams).
With the UnoCSS preset, tokens are utility colors: bg-zg-raised, bg-zg-tray, text-zg-muted, border-zg-hairline, bg-zg-danger/10.
Surfaces#
Surfaces built from the kit's pieces match the app in every theme. Follow these rules:
| Rule | Do |
|---|---|
| Layers | Canvas, tray, raised, plus overlay for anything floating. Sections sit in a tray, controls sit raised on it, menus and dialogs use the overlay |
| Tone before line | Separate layers by lightness. Use --zg-hairline only where tone cannot (a divider under a header, a diff in a tray) |
| Light from above | Dark themes lift with a faint top highlight, light themes with a soft shadow and white top edge. Never hardcode a shadow or surface color |
| Group, then lift | Related controls share one track (.zg-segment). Only the hovered, open or active one is raised |
| Two voices | Sans for content and actions, .zg-meta (small muted tabular mono) for metadata. .zg-section-label is the section label. All text is sentence case, never uppercase or small caps |
| Accent for meaning | You, the model, send and status. Never decoration |
| Piece | Class / component | Use |
|---|---|---|
| Tray | .zg-tray, Tray | A section, grouped rows, a quiet card |
| Raised | .zg-raised (with border), Raised | A card or control on a tray. data-live (live prop) lifts it one more step while a run streams |
| Track and segment | .zg-segment, .zg-segment-item | Controls grouped on one recessed track |
| Overlay | .zg-float | Menus, popovers, dialogs |
| Well | .zg-well | Input fields. It is translucent, so it reads the same on any layer |
| Fill | .zg-fill, .zg-fill zg-fill-danger | Accent-filled buttons (send). With .zg-fill-danger, the destructive commit (delete) |
| Track chip | .zg-track-chip | Status set into a surface |
| Row | .zg-menu-row, MenuRow | Rows in menus, lists, navigation |
Depth transitions live on these classes, never inline, so the motion switch stops them.
Palettes from a seed#
derivePalette(seed) (zidane-ui) turns a ThemeSeed into a contrast-checked ResolvedPalette: body text at least 4.5:1, muted text 3.4:1, status and on-fill text 3:1. Only canvas and accent are required. The engine derives the rest.
ThemeSeed field | Description |
|---|---|
canvas | Window background. Required |
accent | Buttons, links, focus, selection. Required |
appearance | 'light' or 'dark'. Default: inferred from canvas luminance |
text, textMuted, textSubtle | Text colors |
textStrong | Primary text where it reads strongest: the sidebar and the inbox in dark (white by default) |
surface1, surface2, surface3, border, borderStrong | Surface and line overrides |
ring | Focus ring. Default: accent |
accentAlt | Secondary hue (success green in most themes) |
model | Model-identity tint |
success, warn, danger, info | Status hues |
selection, codeBg | Text selection, code background |
diff | Partial<DiffColors>: add/remove backgrounds and foregrounds |
chips | Per-kind chip colors (default, skills, files) |
throbber | The working indicator's two colors |
money | Cost figures |
paletteToCssVars(palette) (zidane-ui) returns the variable map. Add contrastToCssVars and depthToCssVars for the contrast level (100 is neutral).
import { contrastToCssVars, depthToCssVars, paletteToCssVars } from 'zidane-ui'
import { derivePalette } from 'zidane-ui'
const palette = derivePalette({ canvas: '#101418', accent: '#8b5cf6' })
const vars = {
...paletteToCssVars(palette),
...contrastToCssVars(100, palette.appearance),
...depthToCssVars(100, palette.surfaces),
}Built-in themes#
BUILTIN_THEMES (zidane-ui) holds the desktop app's themes by id:
| Id | Label |
|---|---|
default | Default (dark) |
light | Light |
catppuccin-mocha, catppuccin-macchiato, catppuccin-frappe | Catppuccin (dark) |
catppuccin-latte | Catppuccin Latte (light) |
gruvbox-dark, gruvbox-light | Gruvbox |
crush | Crush (Pantera) |
vaporwave | Vaporwave |
seedFromTheme(theme) (zidane-ui) turns one into a seed you can override: derivePalette({ ...seedFromTheme(BUILTIN_THEMES.vaporwave!), accent: '#22c55e' }). themeCssVars(theme, contrast?) (zidane-ui) returns the full map for a theme: a built-in id, or a Theme of your own.
Applying a theme#
Static pages and server renders#
themeStylesheet({ dark?, light?, contrast? }) returns :root.dark { ... } :root.light { ... } for two themes: built-in ids (default and light unless you pick others) or Theme objects of your own, such as your brand's. Render it into a <style> in <head> and set the class on <html> before first paint, as this site does.
import { themeStylesheet } from 'zidane-ui'
const PALETTES = themeStylesheet({ dark: 'catppuccin-mocha', light: 'catppuccin-latte' })
const APPEARANCE = `document.documentElement.classList.add(matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')`
export function Head() {
return (
<>
<script dangerouslySetInnerHTML={{ __html: APPEARANCE }} />
<style dangerouslySetInnerHTML={{ __html: PALETTES }} />
</>
)
}At runtime#
For a user-picked theme or custom seed, write the map onto <html> and toggle the appearance class. withoutTransitions makes the swap instant.
import type { CssVarMap } from 'zidane-ui'
import type { Appearance } from 'zidane-ui'
import { withoutTransitions } from 'zidane-ui'
export function applyTheme(vars: CssVarMap, appearance: Appearance) {
const root = document.documentElement
withoutTransitions(() => {
for (const [name, value] of Object.entries(vars)) root.style.setProperty(name, value)
root.classList.toggle('dark', appearance === 'dark')
root.classList.toggle('light', appearance === 'light')
})
}On one container#
To theme part of a page, put the variables on a container with the zg-theme-scope class and the appearance class. Computed tokens then resolve against the container, as in the playground.
<div className={`zg-theme-scope ${palette.appearance}`} style={vars as React.CSSProperties}>
…
</div>Popovers, tooltips (with portal), row menus and overlays render into <body>, so they take the page's theme.
Light and dark#
The appearance is a .dark or .light class on <html> or a zg-theme-scope container. It sets color-scheme for native controls and scrollbars, picks the palette themeStylesheet renders and drives UnoCSS's dark: variant (presetUno({ dark: { dark: '.dark' } })). With neither class, you get the dark fallbacks.
Contrast#
One level from 50 to 200 scales the UI. contrastToCssVars(level, appearance) fades text toward the canvas below 100 and pushes it toward white or black above.
depthToCssVars(level, surfaces) compresses the surface ladder below 100. Above 150 it goes flat (no shadows, hairlines at full border strength). themeStylesheet and themeCssVars take the same level.
Motion#
html[data-motion] is the motion switch: off, reduced or full (same as no attribute). Stylesheets key transitions on it. In JS, read it with useMotionLevel() or useReducedMotion() (zidane-ui). useReducedMotion() is true for off and reduced.
The kit never reads prefers-reduced-motion, so set the attribute from it or from your own setting:
const reduce = matchMedia('(prefers-reduced-motion: reduce)')
const sync = () => {
document.documentElement.dataset.motion = reduce.matches ? 'reduced' : 'full'
}
sync()
reduce.addEventListener('change', sync)readPanelMs reads --zg-panel-ms for panel open and close. tokens.css sets it to 350ms with springs on, 200ms with html[data-springs="off"], and 0ms when motion is reduced or off. MotionProvider writes the live value.
Reference#
theme/themes, theme/derive, theme/css-vars, theme/stylesheet, theme/without-transitions.