Custom Properties (CSS Variables)
wgpu-html fully implements CSS custom properties (CSS variables) with --* declaration syntax, var() usage, inheritance, recursive substitution, cycle detection, and a programmatic API for runtime manipulation.
--custom-property Syntax
Custom properties are defined using the -- prefix:
:root {
--primary-color: #3498db;
--spacing-unit: 8px;
--border-radius: 6px;
--font-stack: "Inter", system-ui, sans-serif;
}
.button {
background-color: var(--primary-color);
padding: var(--spacing-unit) calc(var(--spacing-unit) * 2);
border-radius: var(--border-radius);
font-family: var(--font-stack);
}
Custom properties can be defined:
- In any stylesheet rule (class, ID, tag,
*) - In inline
style="--color: red;"attributes - Programmatically via
Node::set_custom_property()
var() Usage
The var() function references a custom property:
.element {
color: var(--text-color);
margin: var(--space, 16px); /* with fallback */
}
Fallback Values
A second argument to var() provides a fallback when the custom property is not defined:
color: var(--accent, blue);
padding: var(--custom-gap, 12px);
font-family: var(--heading-font, sans-serif);
The fallback can itself contain var() references (nested):
color: var(--brand-color, var(--fallback-color, black));
Inheritance of Custom Properties
Custom properties always inherit from parent to child. This is a key difference from regular CSS properties:
.container {
--accent: #e74c3c;
}
.container .child {
/* --accent is inherited from .container */
color: var(--accent); /* #e74c3c */
}
Values propagate through the cascade just like regular inheritable properties:
- The cascade resolves explicit
--*declarations for each element - After cascade, unset custom properties are bulk-cloned from the parent
- Programmatic custom properties from
Node::custom_propertiesare injected after inheritance, overriding any inherited value
Recursive Variable Substitution
Variables can reference other variables, and the engine resolves chains recursively:
:root {
--hue: 200;
--saturation: 80%;
--lightness: 50%;
--accent: hsl(var(--hue), var(--saturation), var(--lightness));
--button-bg: var(--accent);
}
.button {
background-color: var(--button-bg);
/* Resolves: hsl(200, 80%, 50%) → #1a8cff */
}
The resolution happens in two phases:
- Phase 1 — resolve
var()inside custom property values (e.g.,--a: var(--b)chains collapse) - Phase 2 — resolve
var()in regular property declarations, re-parsing the substituted value throughapply_css_property()
Cycle Detection
Circular variable references are detected and handled gracefully:
:root {
--a: var(--b);
--b: var(--a);
/* Cycle! Both evaluate to empty (guaranteed-invalid) */
}
The resolver tracks a resolving: HashSet<String> during substitution. When a variable name already exists in the set, a cycle is detected and:
- The
var()evaluates to the fallback value (if provided) - If no fallback, the
var()evaluates to nothing (guaranteed-invalid per CSS spec)
color: var(--a, red); /* cycle + fallback → "red" */
color: var(--a); /* cycle + no fallback → "" → no color set */
Programmatic API
Custom properties can be manipulated from Rust code at runtime:
Node::set_custom_property()
use wgpu_html_tree::Tree;
let mut tree = parse(html);
let node = tree.get_element_by_id("my-element").unwrap();
// Set a custom property
tree.node_ref_mut(node).set_custom_property("--accent", "#ff6600");
// The value participates in var() resolution during the next cascade
Node::remove_custom_property()
tree.node_ref_mut(node).remove_custom_property("--accent");
// The property reverts to its inherited or default value
Node::custom_properties
The custom_properties: HashMap<String, String> field on Node stores programmatic custom properties. These are injected into the cascaded Style after inheritance, overriding any stylesheet-defined or inherited values:
// Direct access
node.custom_properties.insert("--brand-color".into(), "#2c3e50".into());
How Programmatic Properties Flow Through Cascade
In cascade_node() (and re_cascade_dirty()):
// Inject programmatic custom properties from the Node.
for (prop, value) in &node.custom_properties {
style.custom_properties.insert(prop.clone(), value.clone());
}
Then resolve_var_references() runs to substitute all var() references using the final custom property map.
Late Re-Parse Through apply_css_property()
When a property value contains var(), it cannot be fully parsed at declaration time. Instead, it's stored in Style::var_properties as a raw string:
// During parsing:
if value_contains_var(value) {
style.var_properties.insert(property.to_owned(), value.to_owned());
return; // defer parsing
}
After the cascade and inheritance are complete, resolve_var_references() processes all deferred properties:
pub fn resolve_var_references(style: &mut Style) {
// Phase 1: resolve var() in custom property values
// Phase 2: resolve var() in regular property declarations
let pending: Vec<(String, String)> = style.var_properties.drain().collect();
for (prop, raw_value) in pending {
let resolved = substitute_vars(&raw_value, &style.custom_properties, &mut resolving);
if !resolved.is_empty() {
apply_css_property(style, &prop, &resolved);
}
}
}
This means var() can be used with any supported CSS property:
width: var(--sidebar-width);
margin: var(--spacing);
color: var(--text-color);
display: var(--layout-mode); /* "flex" → display: flex */
Code Examples
Design Tokens Pattern
:root {
--color-primary: #3498db;
--color-primary-dark: #2980b9;
--color-danger: #e74c3c;
--color-success: #2ecc71;
--color-text: #2c3e50;
--color-text-muted: #7f8c8d;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
}
.btn {
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
font-size: 14px;
}
.btn-primary {
background-color: var(--color-primary);
color: white;
}
.btn-primary:hover {
background-color: var(--color-primary-dark);
}
.btn-danger {
background-color: var(--color-danger);
color: white;
}
Theme Switching via Programmatic API
fn apply_theme(tree: &mut Tree, theme: &Theme) {
if let Some(root) = tree.root_node_ref_mut() {
for (name, value) in theme.variables() {
root.set_custom_property(name, value);
}
}
}
// Dark theme
let dark = Theme::new()
.var("--bg", "#1a1a2e")
.var("--text", "#e0e0e0")
.var("--accent", "#3498db");
apply_theme(&mut tree, &dark);
Computed Values with var()
:root {
--grid-columns: 3;
--card-gap: 24px;
--sidebar-width: 250px;
}
.grid {
display: grid;
grid-template-columns: repeat(var(--grid-columns), 1fr);
gap: var(--card-gap);
}
.with-sidebar {
display: grid;
grid-template-columns: var(--sidebar-width) 1fr;
gap: var(--card-gap);
}
var() Fallback Chain
.card {
background-color: var(--card-bg, var(--surface-color, white));
border-color: var(--card-border, var(--border-color, #ddd));
box-shadow: var(--card-shadow, var(--shadow-sm, none));
}