Skip to content

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.

accent42 passfallback429

Tokens#

styles/tokens.css defines every variable with dark fallbacks.

GroupVariables
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:

RuleDo
LayersCanvas, 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 lineSeparate layers by lightness. Use --zg-hairline only where tone cannot (a divider under a header, a diff in a tray)
Light from aboveDark 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 liftRelated controls share one track (.zg-segment). Only the hovered, open or active one is raised
Two voicesSans 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 meaningYou, the model, send and status. Never decoration
PieceClass / componentUse
Tray.zg-tray, TrayA section, grouped rows, a quiet card
Raised.zg-raised (with border), RaisedA 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-itemControls grouped on one recessed track
Overlay.zg-floatMenus, popovers, dialogs
Well.zg-wellInput fields. It is translucent, so it reads the same on any layer
Fill.zg-fill, .zg-fill zg-fill-dangerAccent-filled buttons (send). With .zg-fill-danger, the destructive commit (delete)
Track chip.zg-track-chipStatus set into a surface
Row.zg-menu-row, MenuRowRows 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 fieldDescription
canvasWindow background. Required
accentButtons, links, focus, selection. Required
appearance'light' or 'dark'. Default: inferred from canvas luminance
text, textMuted, textSubtleText colors
textStrongPrimary text where it reads strongest: the sidebar and the inbox in dark (white by default)
surface1, surface2, surface3, border, borderStrongSurface and line overrides
ringFocus ring. Default: accent
accentAltSecondary hue (success green in most themes)
modelModel-identity tint
success, warn, danger, infoStatus hues
selection, codeBgText selection, code background
diffPartial<DiffColors>: add/remove backgrounds and foregrounds
chipsPer-kind chip colors (default, skills, files)
throbberThe working indicator's two colors
moneyCost figures

paletteToCssVars(palette) (zidane-ui) returns the variable map. Add contrastToCssVars and depthToCssVars for the contrast level (100 is neutral).

TypeScript
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:

IdLabel
defaultDefault (dark)
lightLight
catppuccin-mocha, catppuccin-macchiato, catppuccin-frappeCatppuccin (dark)
catppuccin-latteCatppuccin Latte (light)
gruvbox-dark, gruvbox-lightGruvbox
crushCrush (Pantera)
vaporwaveVaporwave

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.

TSX
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.

TypeScript
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.

TSX
<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:

TypeScript
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.