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.
| Category | slug / prefix | Constructor | Example custom property |
|---|---|---|---|
| Color | color / --q-color- | Token::color(name) | --q-color-brand |
| Space | space / --q-space- | Token::space(name) | --q-space-4 |
| Radius | radius / --q-radius- | Token::radius(name) | --q-radius-md |
| Typography | font / --q-font- | Token::font(name) | --q-font-size-md |
| Shadow | shadow / --q-shadow- | Token::shadow(name) | --q-shadow-md |
| MotionDuration | duration / --q-duration- | Token::duration(name) | --q-duration-fast |
| Effect | effect / --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 ThemeMode — Light, 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)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.
| Token | Light | Dark | Contrast |
|---|---|---|---|
| color-bg | #f8fafc | #0f172a | #ffffff |
| color-fg | #0f172a | #f8fafc | #000000 |
| color-brand | #4f46e5 | #818cf8 | #4338ca |
| color-surface | #ffffff | #1e293b | #ffffff |
| shadow-md | 0 4px 12px rgba(15,23,42,0.10) | 0 4px 12px rgba(0,0,0,0.50) | none |
| space-4 / radius-md | 16px / 8px | 16px / 8px | 16px / 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.
| Motion | Class | Runtime | Use |
|---|---|---|---|
| None | (no extra class) | none | Default; preserves static SSR output. |
| Press | --m-press | CSS only | Small active-state scale for buttons and controls. |
| Lift | --m-lift | CSS only | Hover/focus elevation for cards and actionable surfaces. |
| Tilt3d | --m-tilt-3d + data-q-tilt | motion island | Pointer-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.
- Emit boot_script_tag(&BootConfig::new(default_mode)) in <head> before any stylesheet.
- Inline default_theme_set().to_css() (or your own ThemeSet) so :root + per-mode blocks load.
- Author component CSS that uses var(--q-...) references via Token::var()/token(...), never hardcoded values.
- For a future _sys_theme catalog / hot-reload, use dump_mode(mode) or dump_all() to get serialization-free (property, value) data, deterministically sorted.