Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Styling

std::web::style gives you typed, checked CSS without writing a stylesheet. You build a Style value in code, the compiler evaluates it during the build and writes real CSS rules into your bundle’s .css file, and at runtime the style is nothing but a set of class names on an element.

If you’ve used Tailwind, the feel is similar (small composable pieces, a spacing scale, color ramps), except the pieces are typed function calls, so a typo is a compile error instead of a silently-ignored class.

import std::web::ui::{ View, mount_root };
import std::web::style::{ style, space, Style, Color, Length, Display, FlexDirection };

let card = const style()
	.display(Display::Flex)
	.flex_direction(FlexDirection::Column)
	.gap(space(2))
	.padding(space(4))
	.radius(space(1))
	.background(Color::gray(100));

fun main() {
	let _root = mount_root("app", || {
		<div .styled(card)>
			<p>"hello"</p>
		</div>
	});
}

The model

  • style() starts an empty style. Every method fills one property and returns the new style, so you chain.
  • Styles are built inside const, the compile-time evaluation prefix (see Macros & const). The rules are emitted during the build.
  • .styled(card) in an element’s head (the View method styled) puts the style’s classes on the element.
  • There is no reset unless you ask for one. Browser defaults are in force, so body keeps its 8px margin and width(px(200)).padding(space(4)) measures 232px, not 200. Add let _reset = const preflight(); for the opinionated base stylesheet (box-sizing: border-box everywhere, margins zeroed, form-control chrome stripped) — it lives in its own cascade sub-layer, so every style you write still wins against it. The reference has what it contains.

The css block

The chain has a second spelling that reads like the CSS you already know. A css { … } block is a Style — it is sugar over the chain above, lowered before anything else in the compiler sees it — so the two forms mix freely in one file, one function, one expression, and both emit exactly the same stylesheet.

Here is one style written both ways, in one program. card and card_as_a_chain mint the same classes: the block does not emit beside the chain, it becomes the chain.

import std::web::ui::{ View, mount_root };
import std::web::style::{ style, space, Style, Color };

let card = css {
	display("flex");
	gap(space(2));
	padding(space(4));
	background-color(Color::gray(100));
	.hover {
		background-color(Color::gray(200));
	}
};

let card_as_a_chain = const style()
	.raw("display", "flex")
	.raw("gap", space(2))
	.raw("padding", space(4))
	.raw("background-color", Color::gray(100))
	.hover(style().raw("background-color", Color::gray(200)));

fun main() {
	let _root = mount_root("app", || {
		<div .styled(card)>
			<p>"hello"</p>
		</div>
	});
}

One rule, and the whole feature falls out of it. An undotted property(value); is a declaration and becomes .raw(property, value); a dotted .name { … } is a condition combinator and becomes .name(style() … ), with the block’s own chain as its last argument. The dot is the only thing the grammar looks at, so every condition method works inside a block — .hover, .md, .children — including ones added later, and so does .on(<set>), the head that takes a condition SET and has no nesting order to get wrong.

let panel = css {
	color(Color::gray(900));

	.on(within(attribute("data-theme").eq("dark"))) {
		color(Color::gray(50));
	}

	.on(hover() + active().not()) {
		color(Color::blue(600));
	}

	.children {
		margin-top(space(2));
	}
};

The condition constructors are ambient inside a block, like the token vocabulary below, so a head needs no import.

A declaration is a call, and its values are ordinary vilan expressions. That is the whole of the value rule: there is no CSS token soup to learn and no { } hole to remember, because a value was never anything but an expression. A typed value keeps its type, which is what carries a token’s :root line onto the sheet, so write gap(space(4)); rather than gap("1rem"); when you mean the scale. Anything CSS can say and vilan cannot is a string — grid-template-columns("repeat(3, 1fr)");, background-image("url(\"tile.png\")"); — and a string is a value like any other.

Several arguments are one value, joined by a single space — CSS’s own list separator. margin(px(4), px(8)); is margin:4px 8px, and border("1px solid", Color::gray(500)); writes 1px solid var(--gray-500) and puts the :root line that declares --gray-500 on the sheet beside it. Where the parts are glued rather than spaced, build the one value yourself with an i-string and piece, which renders a value and carries its token: padding(i"calc({piece(space(4))} + 2px)");.

A custom property is a call head too, and reading one is var:

let themed = css {
	--brand-ink(gray(900));
	color(var("--brand-ink"));
};

Inside a block, the token vocabulary is ambient. rem, px, em, pct, vh, vw, auto and space; black, white, transparent, hex, gray, blue, red, green, rgba and oklch; and the condition constructors a .on(..) head takes — hover, focus, active, disabled, first, last, pseudo, element, attribute, within, children, divide, media, sm, md, lg, xl; and var and piece. That is the std::web::style::prelude module, in scope inside a css block and nowhere else, so a value reads as the CSS it stands for:

let chip = css {
	padding(space(2));
	color(gray(700));
	border("1px solid", gray(300));
	border-radius(rem(0.25));
};

Your own names always win: a let rem = … or an import in scope is what the name means, and the module is only asked when nothing else answers.

Outside a block, import the vocabulary once at the top of the file. std::web::style::prelude is an ordinary module there, so import std::web::style::prelude::{ s, space, gray, hover, Cursor }; binds exactly what the file uses — bare, for the whole file — and import std::web::style::prelude as tokens; qualifies them through a name of your choosing instead. That is the form to reach for in a file whose fun bodies build chains, because a chain written outside a hole gets no ambient scope: one import list at the top beats an import block inside each function, which is what the shape without it turns into.

The keyword-property TYPES are in that module too — Length, Cursor, TextAlign and AlignItems — so one import answers both halves of a chain, the tokens and the keywords:

import std::web::style::prelude::{ s, space, gray, px, hover, active, Cursor, TextAlign };

fun button(): Style {
	s()
		.padding(space(2))
		.color(gray(700))
		.cursor(Cursor::Pointer)
		.text_align(TextAlign::Center)
		.width(px(120))
		.on(hover() + active().not(), s().color(gray(900)))
}

The other keyword enums — Display, Position, FlexDirection, JustifyContent, Overflow, WhiteSpace, UserSelect — stay in std::web::style and are imported from there when a file wants them. The prelude is a vocabulary, not a second spelling of the whole module.

What the module publishes is exactly the names listed above — the 38 functions and the 8 types, 46 in all. Its own machinery is not surface: each of these functions is one line over std::web::style’s constructor of the same name, and the alias it needs to call one (hover here is the condition, style::hover is what it calls) is imported inside that function’s own body rather than at the top of the file, so it never resolves as a second spelling of the token.

A dotted item ending in ; is a chain link — a Style method call, spliced exactly where you wrote it. That is how your own helpers reach a block:

impl Style {
	fun flex_row(self): Style {
		self.display(Display::Flex).flex_direction(FlexDirection::Row)
	}
}

let toolbar = css {
	.flex_row();
	gap(space(2));
	padding(space(1));
};

The dot is the whole rule, and what follows it decides: a { … } body is a condition rule, a ; is a link. vilan fmt treats a link as a barrier — an opaque method may write any property, so nothing sorts across it and its position is preserved.

A block is const on its own. A style is a compile-time asset — the chain records its rules as it is built, and the build writes the ones the program kept onto the stylesheet when evaluation ends — so the chain spelling needs const in front of it, and the block does not: it writes the word for you. let card = css { … }; is the whole declaration. What that does not buy you is reading a runtime value: an argument that reads a function parameter or a signal is refused at the argument, because there is nothing compile-time to put on the sheet. Writing const css { … } yourself still compiles and means exactly the same thing.

Four things the block does not do, each on purpose:

  • The ; is required, including after the last declaration.
  • # and @ begin no expression. A colour is hex("#663399") — which routes it through Color, so its :root line travels with it — and a media query is .md { … }. There are no at-rules; a declaration block under a selector of your own is declare.
  • !important is refused. Merging a style is a record update, so a later declaration on the same property already wins.
  • A block is brace-initial, like a struct literal, so a condition, a for … in iterable and a match subject take one only in parentheses: if (css { … }).class_list() != "" { … }.

vilan fmt orders a block, and orders it exactly as it orders the chain. One item per line, nested rules one level in, arguments tidied like any other vilan expression — and the items sorted into the canonical order: properties in Tailwind’s category sequence, then the condition rules in the order the selector nests them (media, relation, attribute, pseudo-class). So the two spellings of one style format alike, and grouping declarations by hand is not a thing you have to maintain.

Two carve-outs worth knowing. A property no Style method writes — a vendor prefix, a custom property like --brand-ink — is a barrier: it holds its place and nothing sorts across it, because the formatter cannot know what it is entangled with. And a block containing a comment is never reordered at all; it still prints canonically, but the items stay where you wrote them, so a comment can never end up explaining the wrong declaration.

// formats as: display, padding, then `.md` before `.hover`
let button = css {
	.hover { background-color(Color::gray(200)); }
	padding(space(2));
	.md { padding(space(4)); }
	display("flex");
};

What reaches the stylesheet

The sheet holds the rules your program kept, not every rule it ever built. A condition combinator re-mints its inner style’s rules under the composed condition and drops the inner — attribute("data-open", Some("true"), hover(inner)) mints three classes and can only ever put the third on an element — and a shorthand set after a longhand it covers drops that longhand’s slot. Rules are recorded as they are built and the build writes the surviving ones when const evaluation ends, so the scaffolding never ships: only classes the program can still name reach the file.

Getting the stylesheet onto the page

The build writes every emitted rule into a sidecar beside the bundle — app.js gets app.css, dist/client.js gets dist/client.css — and your page has to link it. Nothing injects the tag for you: the HTML shell is yours, not the compiler’s.

<link rel="stylesheet" href="app.css" />

Both vilan init browser templates already carry the line, and the fullstack one serves the sidecar without naming it — serve_build routes every artifact the build wrote, the stylesheet among them:

Server::builder().port(8080).serve_build(require_build("client"))

That also means a leg that stops emitting styles stops having them served, with no fs::stat guard to remember: the build says whether it wrote a sidecar, and the server believes it.

Miss the link and the app runs unstyled while the compiler faithfully rebuilds a stylesheet nobody loads — which is why a server can hold its shell against its build and refuse to start over exactly that:

let page = require_shell("src/app.html", build).html();

require_shell (std::web::document, the reference) checks the file every boot: a shell that links no stylesheet over a build that emitted one stops the server, naming the file and the fix. The fullstack template ships that line. Nothing checks a browser-only project’s index.html — there is no server to check it — so that one is still on you.

A <link> (rather than an inlined <style>) is also what lets --watch hot-swap CSS without reloading the page; see the dev loop.

At runtime you can still select and combine styles you already built. a + b merges two styles (per property, the right side wins), and picking one of two styles in an if is fine. What you can’t do is construct new rules at runtime: a bare style() chain outside const is a compile error. That restriction is what keeps the CSS static and the bundle predictable.

let button = const style().padding_x(space(3)).radius(space(1));
let primary = const button + style().background(Color::blue(600)).color(Color::white());

Values

  • space(step) is the spacing scale: space(1) is 0.25rem, and the steps grow like Tailwind’s. It’s the usual argument to padding, gap, margin, and radius.
  • Length covers everything else: Length::px(1.0), Length::rem(1.5), Length::em(0.02), Length::pct(50.0), Length::vh(100.0), Length::vw(50.0), Length::auto(), Length::zero() for a bare 0, Length::var("--w") for a CSS variable (see dynamic values below), and Length::calc("100% - 2rem") when the value is arithmetic — you write the expression, not the calc(..) wrapper.
  • Length::raw(..) is the verbatim escape, Color::hex’s twin: a complete CSS value written as text, for the functional forms Length does not model — Length::raw("clamp(1100px, 100vw, 1920px)"), min(), max(), env(), fit-content(). Use calc when you are writing arithmetic and want the wrapper supplied; use raw when the value is already whole, which is also what lets one named expression be reused across several properties. An empty value stops the build.
  • Color has Color::white(), Color::black(), Color::transparent(), Color::current() (CSS’s currentColor — the element’s own text colour, for a border or an SVG fill that follows it), Color::hex("#663399"), and stepped ramps like Color::gray(300), Color::blue(600), Color::red(500), Color::green(500).
  • Alpha comes two ways. Color::rgba(27, 6, 13, 0.9) is a literal translucent colour — hex’s twin, for a palette outside the ramps. some_color.alpha(0.08) is this colour at that alpha, and is the one to reach for on a ramp step: it keeps the token underneath, so Color::gray(900).alpha(0.08) still re-themes when --gray-900 changes. Both check their range at build time — alpha(1.5) stops the build.
  • Gradient is a background-image value, not a Color. Gradient::linear(degrees) (0 points up, 90 to the right) or Gradient::radial(RadialExtent::ClosestSide), then .stop(colour, percent) per stop, handed to background_gradient. It is a different slot from background, so a style can set a colour and paint a gradient over it. Two stops minimum. For the image values a Gradient can’t hold — a url() or data URI, a multi-layer list, a positioned or repeating-* gradient — background_image(str) writes the same slot, and background_size(str) sizes it.
  • Keyword properties use enums: Display, Position, FlexDirection, AlignItems, JustifyContent, TextAlign, Cursor, Overflow, WhiteSpace, UserSelect, RadialExtent. Each is a backed enum carrying its CSS keyword (AlignItems::Start is "flex-start"), so the variant is the keyword the browser reads — .value() hands it back and Display::parse(text) goes the other way, None outside the set.
import std::web::ui::{ View, mount_root };
import std::web::style::{ style, space, Style, Color, Gradient, Length, RadialExtent };

let hero = const style()
	.padding(space(6))
	.radius(space(2))
	.background(Color::gray(900))
	.background_gradient(
		Gradient::linear(135.0)
			.stop(Color::rgba(178, 48, 86, 0.9), 0.0)
			.stop(Color::blue(600), 100.0),
	)
	.border_top(Length::px(1), Color::white().alpha(0.14))
	.color(Color::white());

let glow = const style().background_gradient(
	Gradient::radial(RadialExtent::ClosestSide)
		.stop(Color::rgba(235, 104, 46, 0.4), 0.0)
		.stop(Color::transparent(), 100.0),
);

fun main() {
	let _root = mount_root("app", || {
		<div .styled(hero)>
			<div .styled(glow) />
		</div>
	});
}

Some properties take a plain str — font_family, transform, box_shadow, text_decoration, flex, grid_template_columns, background_image, background_size. That isn’t a weaker raw: the property name is still checked and completable, and only the value is a CSS expression there is nothing to validate (a font stack, a transform list). Reach for them the same way you reach for padding.

line-height has two methods rather than one, because it takes two kinds of value and the language has no overloading. Prefer line_height(1.5): a unitless number inherits as a ratio and re-computes against each descendant’s own font size. Reach for line_height_length(Length::px(24)) when a design specifies a leading in absolute units — a length inherits as a computed length and will not track a child that resizes its text. Both write the same slot, so a later one simply replaces an earlier one.

For anything the typed surface doesn’t cover, the escape hatch is raw:

.raw("clip-path", "polygon(0 0, 100% 0, 100% 80%)")
.raw("scroll-margin-top", space(4))
.raw("outline-color", Color::blue(300))

raw takes any property, and any value the CSS channel understands: a complete value written as a str, or a Length or Color — including a theme token like space(4) or Color::blue(300), which carries its own :root declaration onto the stylesheet exactly as a typed property method does. with_length and with_color are the same thing under older names and stay available; they are raw at those two value types.

Reach for the value, not its text. A token is a pair — the reference (var(--space-4)) and the :root line that declares it — and reading the .text field of one hands over the reference alone, so space(4).text puts a var() on the sheet that nothing defines. (That is the field, not the Length::raw(..) constructor above, which is a value in its own right and declares nothing to lose.) Passing space(4) itself keeps the pair together.

The typed surface grows by demand — if you find yourself reaching for raw on the same property repeatedly, that is the evidence a method should exist.

Boxes, edges and borders

Spacing comes in three arities, and the name says which: the whole box (padding, margin), an axis (padding_x, margin_y), or one edge (padding_top, margin_left, and the other six). There is no multi-value shorthand method, because there is nothing it would buy: padding: 8px 16px is padding_y(..).padding_x(..), spelled with the methods you already have. The two axes cover all four edges between them — exactly what the shorthand covers — so the composed form also resolves like the shorthand wherever it meets one (see mixing arities, below).

Borders match: border(width, colour) for all four edges, border_top/border_right/border_bottom/border_left for one, and border_none() to remove one. border_none() fills the same slot the shorthand does, so base.border_none() genuinely takes the border off a style that set one. border_color is its own slot, which is what lets a hover recolour a border without restating its width.

let card = const style()
	.border(Length::px(1), Color::gray(300))
	.hover(style().border_color(Color::blue(600)));

let flush = const card.border_none().margin_left(Length::auto());

Mixing arities is fine, and it resolves in the order you wrote it. A property that covers others — padding over its edges, margin, inset over top/right/bottom/left, border over its parts, background, flex — forms a family, and last-wins holds across the whole family, not just one property. So padding(space(4)).padding_top(space(0)) is 1rem on three edges and 0 on the top, and padding_top(space(0)).padding(space(4)) is 1rem all round: the later whole-box value replaces the edge outright, exactly as a second padding(..) would. The same holds across +, and across raw (a raw("margin-left", "auto") belongs to the margin family like the method does).

// A tight box with one edge opened up, and a card whose border is
// recoloured — both read top to bottom, like the rest of the chain.
let panel = const style().padding(space(4)).padding_top(space(0));
let lit = const card + style().border_color(Color::blue(600));

States and breakpoints

A condition is a value. hover() is one, active().not() is one, and + intersects them into a SET — “both of these at once”. on(conditions, inner) puts a style under a set:

let button = const style()
	.background(Color::blue(600))
	.on(hover(), style().background(Color::blue(500)))
	.on(focus(), style().raw("outline", "2px solid"))
	.on(disabled(), style().opacity(0.5));

The constructors: hover(), focus(), active(), disabled(), first(), last(), and pseudo(name) for any other pseudo-class. A pseudo-ELEMENT is its own value, element("selection") for ::selection — CSS puts one at the end of a compound and lets nothing follow it, which is a rule about the value and not about where you wrote it.

attribute(name) conditions on an attribute of the element itself: attribute("data-selected") is presence — [data-selected], [disabled], the shape a boolean attribute actually has in markup — and attribute("data-open").eq("true") is the exact match [data-open="true"]. .not() negates the condition it is written on, which is the whole reason a condition is a value:

let row = const style()
	.on(attribute("data-selected"), style().background(Color::blue(100)))
	.on(attribute("disabled").not() + hover(), style().background(Color::gray(50)))
	.on(hover().not(), style().opacity(0.6));

That is .sX[data-selected], .sX:not([disabled]):hover — hovered and not disabled — and .sX:not(:hover). Nothing has to be read inside-out: the negation is where the negated thing is.

A set has no ORDER. hover() + md() and md() + hover() are the same set, canonicalise to the same key and mint the same class — so two people who mean one rule get one rule. Two conditions that contradict (hover() + hover().not()) are refused, as are two breakpoints in one set, two ancestor guards, two child relations and two pseudo-elements.

The named combinators stay, as sugar for exactly one condition: .hover(inner) is .on(hover(), inner), and the same for .focus, .active, .disabled, .first, .last, .pseudo(name, inner), .children(inner), .divide(inner), .sm/.md/.lg/.xl and .media(min_width, inner). Most rules have one condition, and for those the sugar is the shorter spelling. Reach for .on(..) when a rule has two.

Breakpoints: sm() (640px), md() (768px), lg() (1024px), xl() (1280px), or media(min_width). All are min-width conditions, so chains are mobile-first: in .sm(grid_cols(2)).lg(grid_cols(3)) the widest matching breakpoint wins (the stylesheet emits media rules in ascending min-width order, which is what makes that true). A set holds at most one.

Theming, and stacking conditions

within(condition) applies under an ancestor matching condition. within(attribute("data-theme").eq("dark")) is the theme condition, under a [data-theme="dark"] switch you set on the document, not prefers-color-scheme. That is deliberate: a server can decide the theme and write the attribute before a byte of JavaScript runs, and a user’s toggle is one attribute write. Nothing is special about the theme: any ancestor state rides — an n-ary theme id (within(attribute("data-theme").eq("iron-dark"))), a density mode, a [data-collapsed] sidebar, or a pseudo-class the ancestor is in (within(hover())).

For colours, the stronger recipe is usually no condition at all: declare per-theme custom properties with a declaration block and read them with Color::var(..) — switching themes then re-paints every element through the variables, and within covers the structural changes a value swap cannot express.

Conditions stack by being summed, in any order you like:

let button = const style()
	.background(Color::gray(100))
	.on(hover(), style().background(Color::gray(200)))
	.on(within(attribute("data-theme").eq("dark")), style().background(Color::gray(800)))
	.on(within(attribute("data-theme").eq("dark")) + hover(), style().background(Color::gray(700)))
	.on(md() + within(attribute("data-theme").eq("dark")) + hover(), style().background(Color::gray(600)));

The emitted selector still nests the way CSS nests it — the breakpoint outermost, then the ancestor guard, then the element’s own attributes and pseudo-classes — and that is the canonicaliser’s job, not yours.

Why it matters beyond spelling: within(..) + hover() produces a more specific selector than either part, so it beats both. Between a plain within(..) rule and a plain hover() rule on the same property the guard wins — a theme shouldn’t be undone by a hover — so when a dark theme needs its own hover colour, say so with the two conditions in one set.

The NESTED spelling still works and still has exactly one legal order: md(within(attribute(hover(..)))). Write any other and the build stops and names both fixes — the nesting it wanted, and the set that has no order to get wrong.

Styling children from the parent

children() conditions on every direct child of the element, and divide() on every direct child but the first — the parent-owned spacing idioms (Tailwind’s space-* and divide-*):

let list = const style()
	.on(children(), style().padding_y(space(2)))
	.on(divide(), style().border_top(Length::px(1), Color::gray(200)));

Two rules make this safe to use anywhere. First, a child’s own style always wins: a rule carrying a child relation is emitted in a lower cascade layer, so anything the child says about itself — through its own style() — overrides what its parent reaches in with, whatever the selectors’ specificity. They set defaults the child may refuse; they are not a way to force a child’s hand. Second, where children and divide touch the same property, divide wins on every child but the first — the narrower relation outranks the blanket, whichever you wrote first.

A guard and a child relation are not the same slot, so they compose: .on(within(attribute("data-theme").eq("dark")) + children(), ..) is [data-theme="dark"] .sX > *, a selector the nested form could never spell. A rule carrying a child relation is layered whatever else conditions it, because it reaches in.

The .children(inner) and .divide(inner) sugar takes an UNCONDITIONED inner style: a pseudo-class under it would bind to the child’s compound, which is not what it looks like it says. Put a state on the child’s own style, or write the set.

Dynamic values

Styles are static, so how does a progress bar grow? Through CSS custom properties. The style declares a variable, and the element binds the variable to a signal with style_var:

import std::web::ui::{ View, mount_root };
import std::web::style::{ style, Style, Length, Color };
import std::reactive::Signal;

let bar = const style()
	.height(Length::rem(0.5))
	.width(Length::var("--progress"))
	.background(Color::green(500));

fun main() {
	let progress = Signal::new("40%");
	let _root = mount_root("app", || {
		<div .styled(bar) .style_var("--progress", progress) />
	});
}

The rule is compiled once. Only the variable’s value changes at runtime. This one channel covers most “dynamic styling” needs — a value that changes inside a rule.

Conditional merges: when

When the style depends on a handful of independent flags, + and if turn into a small pile of rebinding. when(condition, delta) is that pile as a chain — self + delta when the condition holds, self untouched when it doesn’t:

import std::web::ui::{ View, mount_root };
import std::web::style::{ style, space, Style, Color };

let base = const style().padding(space(2)).color(Color::gray(900));
let chosen = const style().background(Color::blue(100)).color(Color::blue(900));
let muted = const style().color(Color::gray(400));

fun row(is_chosen: bool, is_muted: bool): View {
	<li .styled(base.when(is_chosen, chosen).when(is_muted, muted)) />
}

fun main() {
	let _root = mount_root("app", || <ul>{row(true, false)}</ul>);
}

when selects; it never builds. Both sides were constructed in const, so the construct-in-const rule holds with a runtime flag in the middle, exactly as it does for bind_styled below.

Chain order is precedence: when two whens both fire, the later delta wins whatever properties they share — the same rule + follows.

The chain reads best when each condition mentions its own flag. If one condition has to mention another link’s flag (!selected && !disabled), the states aren’t independent, and a match says that structurally where a chain only implies it. The compound condition is the tell.

Swapping whole styles

When what changes is which style applies, not a value inside one, put the style in a signal and bind it. bind_styled is to styled what bind_class is to class:

import std::web::ui::{ View, mount_root };
import std::web::style::{ style, space, Style, Color };
import std::reactive::Signal;

let idle = const style().padding(space(2)).background(Color::gray(100));
let busy = const style().padding(space(2)).background(Color::blue(600));

fun main() {
	let state = Signal::new(idle);
	let _root = mount_root("app", || {
		<div .bind_styled(state)>
			<button on:click(|| state.set(busy))>"start"</button>
		</div>
	});
}

Both styles are built in const, so both sets of rules are in the stylesheet before the page loads; the signal only chooses between class strings that already exist. That is the construct-in-const rule holding with a signal in the middle — you still cannot build a style at runtime, and you never needed to.

Server-side, bind_styled reads the signal once, like every other bind_* on the SSR layer: the style the signal holds when the request is rendered is the one served.

Going deeper. Each property-under-a-condition becomes one atomic CSS rule with a generated class name, deduplicated across the whole build: two styles that both say padding(space(4)) share one class. styled sets class_list(), the space-joined class names. Each combination of conditions is its own slot, so hover(..) and within(.., hover(..)) never fight over one — they are different rules with different class names, resolved by CSS specificity.

Traps

  • A style() chain outside const fails with an “emission outside const” error. Build styles in const, select and merge them at runtime.
  • + is a per-property override, not CSS specificity. The right operand’s value replaces the left’s for the same property and condition.
  • .class(name), .styled(style), .bind_class(..), .bind_styled(..), .bind_attr("class", ..), .toggle_attr("class", ..) and an element head’s class(..) all set the class attribute, so the later call wins — and a reactive one writes again every time its signal changes, so an earlier binding and a later writer take turns. Where one element’s head or chain writes it twice, the compiler refuses it, naming both writers (<div class("x") .styled(card) /> would keep only card’s classes). Use one mechanism per element: two styles compose with + into one .styled(a + b), and custom classes can ride along via .raw. A writer reached through a function of your own (boxed().class("x")) is not seen, and keeps last-wins.
  • A shorthand and its own longhands (padding with padding_top, border with border_color) resolve by the order you wrote them, not by specificity: a later longhand narrows the shorthand, a later shorthand replaces the whole family. This holds per condition, so a within or hover variant of one family never disturbs the base.

Full method table: the style reference.