Skip to content

Quill docs

Design tokens & theme system

Design tokens are the named, themeable values your UI is built from — colors, spacing, radii, typography, shadows, motion durations, and composite effects. In Quill each token is a typed handle to one CSS custom property in the --q- namespace, and a theme is just a map of token values for one mode (light, dark, or high-contrast). A ThemeSet bundles a base theme with per-mode override themes and generates two things: the CSS-variable blocks (a :root base plus one :root[data-q-theme="<mode>"] block per mode) and a tiny no-flicker boot script that sets a single attribute before first paint. Switching themes flips that one attribute and every var(--q-...) re-resolves at once — no per-element restyle, no reflow. This is the qquill-theme crate: zero third-party dependencies, std only. The crate is headless/static: it emits CSS text and a boot string but ships no client signal runtime (a live in-page toggle is Phase 3).

Tokens and categories#

A Token is a strongly-typed (Category, name) handle. The category plus name deterministically derive the custom-property name, e.g. Token::color("brand")--q-color-brand. Authoring code never writes var(--q-...) by hand and never hardcodes a hex value — you reference a token and let the active mode supply the value. Because the *reference* is mode-independent, one attribute flip re-points every reference at once.

Categoryslug / prefixConstructorExample custom property
Colorcolor / --q-color-Token::color(name)--q-color-brand
Spacespace / --q-space-Token::space(name)--q-space-4
Radiusradius / --q-radius-Token::radius(name)--q-radius-md
Typographyfont / --q-font-Token::font(name)--q-font-size-md
Shadowshadow / --q-shadow-Token::shadow(name)--q-shadow-md
MotionDurationduration / --q-duration-Token::duration(name)--q-duration-fast
Effecteffect / --q-effect-Token::effect(name)--q-effect-glass-surface

Category::all() returns these seven in that deterministic order. Each token offers .custom_property() (the --q-... name), .var() (the var(--q-...) reference), and .var_or(fallback) (var(--q-..., fallback)). The free function token(tok) is equivalent to tok.var(), and Token's Display impl yields the same, so a token can drop straight into a CSS value format string. The name segment is sanitized to [a-z0-9-] (uppercase lowercased, other chars become -, leading/trailing/duplicate dashes collapsed), so a token can never produce a malformed or injecting property.

Request

use qquill_theme::{Token, token};

assert_eq!(Token::color("brand").custom_property(), "--q-color-brand");
assert_eq!(Token::color("brand").var(), "var(--q-color-brand)");
assert_eq!(token(Token::color("brand")), "var(--q-color-brand)");
assert_eq!(Token::color("brand").var_or("#000"), "var(--q-color-brand, #000)");

// Names are sanitized:
assert_eq!(Token::color("Brand Primary!!").custom_property(), "--q-color-brand-primary");

Response

All assertions pass.

Output

var_or keeps `)` so functional fallbacks like rgb(...)/calc(...) work, but strips ; { } < and control chars from the fallback.

Themes and modes#

A Theme is a token-value map for one ThemeModeLight, Dark, or Contrast (slugs "light", "dark", "contrast"). Build one with Theme::new(mode) then .set(token, value) (builder-style) or .insert(token, value) (mutating, for loops). Values are sanitized on insert and stored in a BTreeMap keyed by the custom-property name, giving deterministic emission order and last-write-wins dedup. theme.get(token) reads back a resolved value; theme.entries() and theme.entries_in(category) iterate sorted pairs.

The theme!{} macro is ergonomic authoring for a per-mode Theme. The first token is the mode ident; the body is category(name) => value; entries, where category is one of color, space, radius, font, shadow, duration, effect. It produces the same Theme as the equivalent builder calls.

Request

use qquill_theme::{theme, Token, ThemeMode};

let dark = theme! { dark
    color("bg") => "#0f172a";
    color("fg") => "#f8fafc";
    space("4")  => "16px";
};

assert_eq!(dark.mode(), Some(ThemeMode::Dark));
assert_eq!(dark.get(Token::color("bg")), Some("#0f172a"));

Response

// equivalent builder form (compares equal):
Theme::new(ThemeMode::Dark)
    .set(color_bg(), "#0f172a")
    .set(color_fg(), "#f8fafc")
    .set(Token::space("4"), "16px")

ThemeSet and CSS-variable-only switching#

A ThemeSet bundles one base theme (whose values populate the attribute-less :root block that paints before any JS runs) with per-mode override themes. Build with ThemeSet::new(base_theme) then .with_mode(theme) per override. to_css() emits the :root base block followed by one :root[data-q-theme="<mode>"] block per registered mode, in deterministic order. base_css() and mode_css(mode) emit those pieces individually (they compose back to the full to_css()). resolve(mode, token) returns the effective value (mode override if present, else base) — what the browser would compute.

<html>                      generated CSS
  data-q-theme="dark"  ---> :root{ --q-color-bg:#f8fafc; ... }            (base = light)
        |                  :root[data-q-theme="light"]{ --q-color-bg:#f8fafc; ... }
        |                  :root[data-q-theme="dark"]{ --q-color-bg:#0f172a; ... }
        |                  :root[data-q-theme="contrast"]{ --q-color-bg:#ffffff; ... }
        v
  flip ONE attribute  ==>  every var(--q-...) re-resolves against the matching block
                           (no per-element style, no class churn, no reflow)
How a theme switch works at runtime

Request

use qquill_theme::{default_theme_set, ThemeMode, color_bg, color_fg};

let set = default_theme_set();
let css = set.to_css();

// resolve effective values per mode:
let light_bg = set.resolve(ThemeMode::Light, color_bg());
let dark_bg  = set.resolve(ThemeMode::Dark, color_bg());
let dark_fg  = set.resolve(ThemeMode::Dark, color_fg());

Response

// css contains, in order:
:root{ ...base light tokens... }
:root[data-q-theme="light"]{ ... }
:root[data-q-theme="dark"]{ ... }
:root[data-q-theme="contrast"]{ ... }

// resolved values:
light_bg == Some("#f8fafc")
dark_bg  == Some("#0f172a")
dark_fg  == Some("#f8fafc")

The default Qirava palette#

default_theme_set() returns the full Qirava set: a light base plus light/dark/contrast overrides. Colors are drawn from the brand assets (brand indigo #4f46e5, brand cyan, slate dark #0f172a, slate light #f8fafc). Typed accessor functions return the token handles — e.g. color_brand(), color_accent(), color_bg(), color_surface(), color_surface_selected(), color_fg(), color_muted(), color_border(), color_border_strong(), color_on_brand() — plus radius(name), shadow_elevation(name), and the effect handles effect_glass_blur/surface/border, effect_neu_raised/inset, effect_gradient_brand/angle.

TokenLightDarkContrast
color-bg#f8fafc#0f172a#ffffff
color-fg#0f172a#f8fafc#000000
color-brand#4f46e5#818cf8#4338ca
color-surface#ffffff#1e293b#ffffff
shadow-md0 4px 12px rgba(15,23,42,0.10)0 4px 12px rgba(0,0,0,0.50)none
space-4 / radius-md16px / 8px16px / 8px16px / 8px (mode-independent)

Motion and 3D interaction axis#

Quill motion is a design-system axis, not a page-specific animation hack. Styled components can opt into Motion::Press, Motion::Lift, or Motion::Tilt3d while default output stays static (Motion::None). The emitted CSS references duration, shadow, and custom-property tokens, so a host theme can globally slow, sharpen, or disable interaction motion without hardcoding each component.

MotionClassRuntimeUse
None(no extra class)noneDefault; preserves static SSR output.
Press--m-pressCSS onlySmall active-state scale for buttons and controls.
Lift--m-liftCSS onlyHover/focus elevation for cards and actionable surfaces.
Tilt3d--m-tilt-3d + data-q-tiltmotion islandPointer-follow perspective rotation and glare for hero/product cards.

Request

use qquill_design::{Card, Motion, Effect, Radius};
use qquill_view::{el, text};

let card = Card::new("hero-card")
    .effect(Effect::Glass)
    .radius(Radius::Xl)
    .motion(Motion::Tilt3d)
    .body(el("p").child(text("Pointer-follow 3D surface")))
    .render();

Response

// card.node() includes:
//   class="qq-card ... qq-card--m-tilt-3d"
//   data-q-tilt
//
// card.style().to_css() includes:
//   perspective(var(--q-motion-perspective,900px))
//   rotateX(var(--q-tilt-rx,0deg)) rotateY(var(--q-tilt-ry,0deg))
//
// card.island() == Some(IslandHint { kind: "motion", trigger: OnLoad })

Output

With JavaScript off the card remains flat and usable. With the runtime loaded, the `motion` behavior writes the pointer-derived `--q-tilt-*` variables. Reduced-motion users get no pointer animation.

The no-flicker boot script#

boot_script(&cfg) returns the only JavaScript this crate emits: a tiny (~400 B) inline script meant to run synchronously in <head> before first paint, so the correct theme is applied with no flash of the wrong colors. It does exactly one mutating thing — set the single data-q-theme attribute on <html>. boot_script_tag(&cfg) wraps it in <script>...</script>. Configure with BootConfig::new(default_mode), then optionally .storage_key(key) (default "q-theme") and .honor_os_preference(bool) (default true).

Resolution order, first match wins: (1) a valid mode slug stored in localStorage[key]; (2) if honoring OS preference and prefers-color-scheme: dark matches, dark; (3) the configured default mode. The script is defensive — a try/catch around localStorage falls back to the media query and then the default, and an unknown stored value is ignored. It only READS storage and the media query; it never writes storage, touches classes, or sets inline styles. Reactivity (a toggle UI, persistence, reacting to OS-preference changes) is a Phase-3 client-runtime concern.

Request

use qquill_theme::{boot_script, boot_script_tag, BootConfig, ThemeMode};

let cfg = BootConfig::new(ThemeMode::Light);
let js  = boot_script(&cfg);
let tag = boot_script_tag(&cfg);

Response

// js:
(function(){try{var e=document.documentElement,t=localStorage.getItem('q-theme');if(!/^(?:light|dark|contrast)$/.test(t||''))t='';!t&&self.matchMedia&&matchMedia('(prefers-color-scheme:dark)').matches&&(t='dark');e.setAttribute('data-q-theme',t||'light')}catch(_){document.documentElement.setAttribute('data-q-theme','light')}})();

// tag:
<script>...the js above...</script>

Output

With .honor_os_preference(false) the matchMedia clause is omitted. With BootConfig::new(ThemeMode::Dark) the fallback becomes ||'dark'. The script stays under 500 bytes and is fully deterministic.

Putting it in the page#

The intended wiring in your SSR <head>: (1) the boot script tag FIRST so it sets data-q-theme before paint; (2) the generated theme CSS (set.to_css()) inlined so the base :root and all per-mode blocks are available; (3) the rest of your styles, which reference tokens via token(...) / Token::var(). Because the boot script and the CSS both key off the same data-q-theme attribute and the same mode slugs, first paint always matches the resolved mode.

  1. Emit boot_script_tag(&BootConfig::new(default_mode)) in <head> before any stylesheet.
  2. Inline default_theme_set().to_css() (or your own ThemeSet) so :root + per-mode blocks load.
  3. Author component CSS that uses var(--q-...) references via Token::var()/token(...), never hardcoded values.
  4. For a future _sys_theme catalog / hot-reload, use dump_mode(mode) or dump_all() to get serialization-free (property, value) data, deterministically sorted.