Skip to content

Theming

Status: implemented in crates/zeyn/assets/zeyn.css.

The default UI kit reads every color, font, size and radius from CSS custom properties on :root. An organization changes the look by overriding a few of them. It never needs to edit zeyn.css or write selectors.

1. How a theme is applied

The server serves zeyn.css first and then, when the app has one, an extra stylesheet with token overrides. The override file only contains :root blocks:

:root {
  --z-brand: #7a2448;
  --z-font: "Source Sans 3", "Segoe UI", system-ui, sans-serif;
  --z-radius: 8px;
  --z-density: 0.75;
}

Rules for the override file:

  • It must be served from the app's own origin. The Content Security Policy is style-src 'self', so inline <style> blocks and third party stylesheets are blocked.
  • Fonts must already be installed on the users' machines or be served from the app's origin. The kit itself loads no web fonts.
  • Load it after zeyn.css, so equal selectors win by order.
  • Set the base tokens in section 2. The derived tokens in section 3 follow automatically. Override a derived token only when the computed value does not work for your brand.

Light, dark and app themes

Every app has a theme picker in the menu, next to the language picker. It offers System (follows prefers-color-scheme), Light, Dark and any theme the app defines:

App::new("Billing")
    .theme(Theme::new().brand("#7a2448").radius(6))
    .themes([
        Theme::light(),
        Theme::dark(),
        Theme::named("sand", [("--z-bg", "#f5f1ea"), ("--z-surface", "#fffdf8")]).label(t!("theme.sand")),
    ])
    .default_theme("system")
  • App::theme is the organization's look and applies under every theme.
  • App::themes lists what users can choose. Without it the list is Light and Dark. System is offered when both are in the list. Theme::named(...) builds on the light theme; .dark_base() builds it on the dark one.
  • App::default_theme is the theme of users who have not chosen. system unless set.
  • App::theme_picker(false) hides the picker; everyone gets the default theme.
  • A single theme locks the app: .themes([Theme::named("acme", tokens)]) or .theme(Theme::dark()).

Tokens are checked when the app starts. App::check (and so router and serve) reports unknown token names, values that could leave their declaration (;, braces, \, <, >, url(), bad theme names, an unknown default theme, and every color pair of section 5 that a theme pushes below WCAG 2.2 AA. Only #rgb and #rrggbb values can be checked for contrast; other color syntax is trusted.

All themes are served in one stylesheet, /_zeyn/theme.css, built once at startup and loaded after the kit's stylesheets. The choice is stored in the zeyn_theme cookie and, for a signed in user in an app with a view store (App::datagrid_views), in that store, so it follows the user to a new device. The server renders the theme on <html>, so the first paint is already right and no inline script is needed:

MarkupResult
<html>System: follows prefers-color-scheme
<html data-theme="light">Always light
<html data-theme="dark">Always dark
<html data-theme="light" data-zeyn-theme="sand">The app theme sand, on the light theme

Changing the theme in the picker only changes these attributes and the color-scheme meta, then calls POST /_zeyn/theme. Nothing reloads.

To give the dark theme its own values in a hand written override file, repeat the selectors the kit uses:

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --z-bg: #0e1012; }
}
:root[data-theme="dark"] { --z-bg: #0e1012; }

Density

--z-density scales the spacing unit. 1 is regular, 0.75 is compact. <html data-density="compact"> sets 0.75 without a stylesheet. Controls and grid rows never get smaller than 24 px, so compact mode still meets the WCAG 2.2 target size.

The row height of a virtual grid is fixed by the server (Paging::virtual_rows(px), 32 px by default) because the runtime computes scroll positions from it. Density does not change it.

2. Base tokens

These are the tokens an organization is expected to set.

TokenPurposeLightDark
--z-brandThe one brand color. Primary buttons, the app name band, current menu item, focused row, focus ring#0f6674same value; see --z-accent
--z-on-brandText on a solid --z-brand surface#ffffff#ffffff
--z-fontInterface font stack"Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, "Noto Sans", sans-serifsame
--z-font-monoCode, API examplesui-monospace, "Cascadia Mono", "SF Mono", "Segoe UI Mono", Menlo, Consolas, monospacesame
--z-font-sizeBody text0.875rem (14 px)same
--z-font-size-smGrid cells, labels, filters0.8125rem (13 px)same
--z-font-size-xsColumn headers, menu group titles, page info0.75rem (12 px)same
--z-radiusBase corner radius, 0 to 12 px. Every component scales from it4pxsame
--z-densitySpacing multiplier, 1 regular, 0.75 compact1same
--z-bgPage background behind panels#f4f5f6#121416
--z-surfacePanels, grids, windows, inputs#ffffff#1a1d20
--z-surface-2Grid header, zebra rows, window footer, read only inputs#f7f8f9#202326
--z-hoverHover background for rows, menu items, buttons#eceef1#2a2e33
--z-borderDividers and panel outlines#dcdfe3#33383e
--z-border-strongInput and button outlines, header rule. Must reach 3:1 against --z-surface#7d848d#7a818a
--z-textBody text and values#1a1d21#e4e6e9
--z-text-mutedLabels, headers, secondary text. Must reach 4.5:1 against --z-surface-2#5b626b#a2a9b1
--z-dangerErrors, required marks, danger rows#b3261e#f28b82
--z-warningWarning rows#8a5300#e6b45a
--z-successSuccess rows#1e6b3a#6fcf8f
--z-infoInfo toasts#2b5d8a#8ab8e6

3. Derived tokens

Computed from the base tokens with color-mix() and relative color syntax. They update by themselves when a base token changes.

TokenPurposeLightDark
--z-accentBrand color used as text or a thin line on a surface: links, current menu item, focused row barvar(--z-brand)oklch(from var(--z-brand) 0.8 c h), the brand hue at a fixed light tone. Browsers without relative color syntax get color-mix(in oklab, var(--z-brand) 45%, #fff)
--z-primaryPrimary button fillvar(--z-brand)var(--z-accent)
--z-on-primaryPrimary button textvar(--z-on-brand)#121416
--z-primary-hoverPrimary button hoverbrand 86% mixed with blackaccent 84% mixed with white
--z-primary-activePrimary button pressedbrand 74% mixed with blackaccent 70% mixed with white
--z-brand-subtleTint behind the current menu item and the focused grid rowaccent at --z-tint over --z-surfacesame formula
--z-focusFocus ringvar(--z-accent)var(--z-accent)
--z-danger-subtle, --z-warning-subtle, --z-success-subtle, --z-info-subtleRow and message backgroundsstatus color at --z-tint over --z-surfacesame formula
--z-tintStrength of the subtle tints9%20%
--z-spaceSpacing unitcalc(4px * var(--z-density))same
--z-controlButton and input heightmax(24px, 8 * --z-space) (32 px)same
--z-rowGrid row height for paged and infinite gridsmax(24px, 8 * --z-space) (32 px)same
--z-gapGap between screen sections3 * --z-space (12 px)same
--z-radius-sm, --z-radius-lgSmall parts (filters, code), windows0.75x and 1.5x of --z-radiussame
--z-shadow-2Windows and toasts only. Grid rows never get a shadowsoft two layer shadowdarker
--z-backdropBehind a modal windowrgb(20 24 28 / 40%)rgb(0 0 0 / 60%)

Per element tokens set by the server markup: --z-width (window width), --z-flex (column share), --z-row-height (virtual grid row height), and for the component library --z-stack-gap, --z-split, --z-progress, --z-columns, --z-level and --z-pop-x / --z-pop-y (popup position).

Chart series colors are --z-chart-1 to --z-chart-6 in components.css, with lighter values in the dark theme. Each reaches 3:1 against --z-surface. Override them like any base token.

4. Choosing a brand color

  • --z-brand carries white text in the light theme (buttons, app name band). Pick a color with at least 4.5:1 against --z-on-brand. Most mid to dark brand colors pass. For a light brand color such as yellow, also set --z-on-brand to a dark color.
  • In the dark theme the kit lightens the brand for text, rings and primary buttons, and keeps the solid brand for the app name band. A different brand looks deliberate in both themes without a second color.
  • Keep status colors distinct from the brand. A red brand next to red error rows makes errors harder to spot; in that case shift --z-danger toward a different red or keep the brand off grids by overriding --z-brand-subtle.
  • Check your colors with the table in section 5 as a target. Any pair below the threshold fails WCAG 2.2 AA.

5. Verified contrast

Measured in Chromium on the running example app: every token was resolved to its rendered sRGB value (including color-mix() and relative colors), then the WCAG 2.x contrast ratio was computed. Thresholds: 4.5:1 for text, 3:1 for UI component outlines and focus indicators.

PairLightDarkNeeded
--z-text on --z-bg15.4914.774.5
--z-text on --z-surface16.9113.544.5
--z-text on --z-surface-215.9112.634.5
--z-text on --z-hover14.5510.934.5
--z-text on --z-brand-subtle (focused row)14.799.274.5
--z-text on --z-danger-subtle14.739.764.5
--z-text on --z-warning-subtle14.829.314.5
--z-text on --z-success-subtle14.769.334.5
--z-text-muted on --z-bg5.657.784.5
--z-text-muted on --z-surface6.177.134.5
--z-text-muted on --z-surface-2 (headers)5.806.654.5
--z-text-muted on --z-hover5.315.764.5
--z-text-muted on --z-danger-subtle (muted row)5.375.144.5
--z-text-muted on --z-warning-subtle5.414.904.5
--z-accent on --z-surface (links)6.629.334.5
--z-accent on --z-bg6.0610.184.5
--z-accent on --z-brand-subtle (current menu item)5.796.394.5
--z-on-primary on --z-primary6.6210.184.5
--z-on-primary on --z-primary-hover8.8211.264.5
--z-on-primary on --z-primary-active11.1212.264.5
--z-on-brand on --z-brand (app name band)6.626.624.5
--z-danger on --z-surface (field errors)6.547.094.5
--z-danger on --z-danger-subtle5.695.114.5
--z-warning on --z-surface6.338.904.5
--z-warning on --z-warning-subtle5.556.124.5
--z-success on --z-surface6.528.874.5
--z-info on --z-surface6.928.134.5
--z-focus on --z-surface6.629.333
--z-focus on --z-bg6.0610.183
--z-focus on --z-brand-subtle5.796.393
--z-border-strong on --z-surface (input outline)3.784.303
--z-border-strong on --z-surface-23.554.013

--z-border is decorative (dividers between items that already have text) and is not held to 3:1.

6. Other accessibility behavior

  • Every interactive element shows a 2 px :focus-visible ring in --z-focus. Grid rows draw the ring inside the row so it is never clipped by the scroll container.
  • Buttons, inputs, sort headers, filter boxes, pager buttons and the window close button are at least 24 x 24 px at any density.
  • prefers-reduced-motion: reduce removes the toast entrance and all transitions.
  • In forced-colors mode icons use CanvasText, the primary button and the focused row use Highlight, and state that relies on background tints (current menu item, row status) gets an outline or border instead.