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 (theViewmethodstyled) puts the style’s classes on the element.- There is no reset unless you ask for one. Browser defaults are in
force, so
bodykeeps its 8px margin andwidth(px(200)).padding(space(4))measures 232px, not 200. Addlet _reset = const preflight();for the opinionated base stylesheet (box-sizing: border-boxeverywhere, 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 ishex("#663399")— which routes it throughColor, so its:rootline travels with it — and a media query is.md { … }. There are no at-rules; a declaration block under a selector of your own isdeclare.!importantis 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 … initerable and amatchsubject 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 topadding,gap,margin, andradius.Lengthcovers 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 bare0,Length::var("--w")for a CSS variable (see dynamic values below), andLength::calc("100% - 2rem")when the value is arithmetic — you write the expression, not thecalc(..)wrapper.Length::raw(..)is the verbatim escape,Color::hex’s twin: a complete CSS value written as text, for the functional formsLengthdoes not model —Length::raw("clamp(1100px, 100vw, 1920px)"),min(),max(),env(),fit-content(). Usecalcwhen you are writing arithmetic and want the wrapper supplied; userawwhen the value is already whole, which is also what lets one named expression be reused across several properties. An empty value stops the build.ColorhasColor::white(),Color::black(),Color::transparent(),Color::current()(CSS’scurrentColor— the element’s own text colour, for a border or an SVG fill that follows it),Color::hex("#663399"), and stepped ramps likeColor::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, soColor::gray(900).alpha(0.08)still re-themes when--gray-900changes. Both check their range at build time —alpha(1.5)stops the build. Gradientis abackground-imagevalue, not aColor.Gradient::linear(degrees)(0 points up, 90 to the right) orGradient::radial(RadialExtent::ClosestSide), then.stop(colour, percent)per stop, handed tobackground_gradient. It is a different slot frombackground, so a style can set a colour and paint a gradient over it. Two stops minimum. For the image values aGradientcan’t hold — aurl()or data URI, a multi-layer list, a positioned orrepeating-*gradient —background_image(str)writes the same slot, andbackground_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::Startis"flex-start"), so the variant is the keyword the browser reads —.value()hands it back andDisplay::parse(text)goes the other way,Noneoutside 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.styledsetsclass_list(), the space-joined class names. Each combination of conditions is its own slot, sohover(..)andwithin(.., hover(..))never fight over one — they are different rules with different class names, resolved by CSS specificity.
Traps
- A
style()chain outsideconstfails with an “emission outside const” error. Build styles inconst, 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’sclass(..)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 onlycard’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 (
paddingwithpadding_top,borderwithborder_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 awithinorhovervariant of one family never disturbs the base.
Full method table: the style reference.