Every built-in ships styled, and a theme changes the whole site by changing
its tokens. When you need more, an element takes a style { } block of
real CSS, scoped to that element, with tokens and live values spliced in.
A theme is a set of tokens, each of which becomes a CSS custom property
(--color-primary). A theme declaration overrides what the baseline
carries; every token not listed keeps its baseline value:
theme Brand {
color-primary: #0F766E
color-secondary: #134E4A
color-background: #FFFFFF
color-surface: #F8FAFC
color-text: #0F172A
color-text-muted: #64748B
color-border: #E2E8F0
font-family: 'Inter', system-ui, sans-serif
font-family-mono: 'JetBrains Mono', monospace
radius-md: 14px
spacing-lg: 2.5rem
shadow-md: 0 4px 12px rgba(0,0,0,0.08)
}
A project with one theme uses it; with several, webfluent.app.json chooses:
"theme": { "name": "Brand" }. Values are raw CSS to the end of the line.
The baseline carries 71 tokens in groups — colour, font, spacing, radius,
shadow, motion, easing, breakpoints, code and layout — every one a CSS custom
property on :root, readable from a style block as $name and from a
stylesheet as var(--name). Design tokens lists each
with its baseline value. Moving a breakpoint moves every media query the
build writes with it.
A theme may add tokens of its own (surface-raised: #131519); they become
custom properties like the rest. The contrast lint (A13) checks text
against background pairs in the theme and warns below WCAG AA.
theme.builtin in webfluent.app.json: full (default) ships the baseline look of
every built-in you use; structural ships only their layout and mechanics
— focus rings, overlays, the grid — for a site that brings its whole
design.
page Styled(path: "/") {
state accent = "#D97706"
state wide = false
Card {
style {
padding: $lg // a token, resolved by the property: spacing-lg
background: $surface // color-surface
border: 1px solid $border
border-radius: $radius-lg
box-shadow: $shadow-md
border-left: 4px solid {accent} // a live value: repaints when `accent` changes
max-width: {if wide { "100%" } else { "40rem" }}
&:hover { box-shadow: $shadow-lg }
& > p { margin: 0 }
@media (max-width: 640px) { padding: $sm }
}
Text("Styled card")
Button("Widen") { on click { wide = !wide } }
}
}
- A style block holds CSS declarations, one per line or
;-separated. No quotes around values —padding: 2rem, not"2rem". A comma between two declarations (width: 2rem, flex: none) is an error: it is CSS, not a map. $tokennames a token. A short name is resolved by the property:padding: $lgisspacing-lg,color: $primaryiscolor-primary,border-radius: $lgisradius-lg,box-shadow: $mdisshadow-md,font-size: $xlisfont-size-xl,transition: $fast. A full name ($color-primary,$surface-raised) works anywhere. Check a token's spelling: one no theme defines resolves to nothing, and the browser drops the declaration.{expr}splices a live value; the declaration updates when the value changes. A whole value or a part of one ({n}px).&:hover,&:focus-visible,&.active,& > p,&[aria-current]nest as CSS nesting does;@media (…) { }nests too.- The block is scoped: it becomes a class on that element alone, written to the page's stylesheet at build time. Splices become custom properties set by the runtime.
The static parts of every style { } are compiled to CSS; the linter warns
when a declaration is invalid CSS.
page Fade(path: "/") {
state active = false
Card {
style { opacity: {if active { 1 } else { 0.4 }}; transform: {if active { "none" } else { "scale(0.98)" }} }
transition { opacity: $fast; transform: 200ms ease-out }
Text("Fades")
}
Button("Toggle") { on click { active = !active } }
}
transition { prop: duration easing } sets CSS transitions for the
element; a $fast/$normal/$slow token or a raw value. Reduced-motion
readers get none (chapter 20).
class: "name" adds a class from your own stylesheet; a .css file in
src/ is bundled into styles.css, and meta.stylesheets in webfluent.app.json
links external ones.
page Classes(path: "/") {
state active = true
Container(class: "hero") {
Heading("Welcome", class: if active { "title is-on" } else { "title" }).h1
}
}
The value may be an expression, so a class can follow state. When several
classes each have their own condition, give a map — each key is a class, on
while its value is true — or a list, which joins strings and maps and skips
what is null:
page Todos(path: "/") {
state done = false
state urgent = true
state tone = "calm"
Text("Water the plants", class: { "is-done": done, "is-urgent": urgent })
Container(class: ["panel", tone, if urgent { "is-loud" }]) { Text("…") }
}
A map's values must be conditions (Bool), and a bare condition is not a
class (T01) — { "is-on": on } says what on turns on. Only the classes
class: itself named come off when its value changes, so a class a script
added with classList.add stays where it is.
Built-ins wear wf-* classes (wf-btn, wf-btn--primary, wf-card),
stable across releases, so a stylesheet may target them. Adding one to an
element with class: brings another built-in's rules with it, and draws a
V04.
Three layers of CSS meet on a page, in this order of strength:
- The built-ins' own rules (
.wf-btn,.wf-card), shipped instyles.css. - Your stylesheets (
src/**/*.css), bundled after them — so on equal specificity, yours win. style { }blocks, compiled to rules with tripled specificity, and nested state and media rules marked!important— so an element's own block beats any sheet, as the inline style it replaces would.
So: to restyle one element, use its style { }. To restyle every button,
write .wf-btn { … } in a stylesheet, or change the tokens in a theme — the
built-ins read tokens, so color-primary recolours every primary button,
link and focus ring at once. theme.builtin: "structural" drops the
built-ins' look entirely and keeps only their layout and mechanics, for a
project that brings its whole design.
A component's enum props are written to its root element as data-<prop>,
so one stylesheet rule styles every variant (chapter 11):
.note[data-tone="loud"] { font-weight: 700; color: var(--color-danger) }A second theme declaration named in webfluent.app.json as "theme": { "name": "Light", "dark": "Night" } stands in for dark mode. Its tokens override the
light theme's under the reader's system preference
(prefers-color-scheme: dark) and whenever setTheme("dark") was called;
setTheme("light") forces light, setTheme("system") follows the system
again. The choice is remembered across visits and applied before the first
paint, so a dark reader never sees a light flash.
theme Light {
color-background: #FFFFFF
color-text: #0F172A
}
theme Night {
color-background: #0B1220
color-surface: #111827
color-text: #E5E7EB
color-text-muted: #9CA3AF
color-border: #1F2937
}
page Prefs(path: "/") {
Text("You are on the {theme} theme")
Row(gap: .sm) {
Button("Light") { on click { setTheme("light") } }
Button("Dark") { on click { setTheme("dark") } }
Button("System") { on click { setTheme("system") } }
}
}
Everything that uses tokens — the built-ins, your $token styles — switches
with it; a color written raw does not, so prefer tokens. The theme value
is "light", "dark" or "system".
Most layout needs no style at all: Stack, Row, Grid, Container,
Spacer and Divider with their gap:, align:, justify: and
columns: props (chapter 7).
page Layout(path: "/", title: "Layout", description: "A grid of projects.") {
Container {
Stack(gap: .lg) {
Row(justify: .between, align: .center) {
Heading("Projects").h2
Button("New").primary.sm
}
Grid(columns: { base: 1, md: 2, lg: 3 }, gap: .md) {
for n in 1..=6 { Card { Text("Project {n}") } }
}
}
}
}
Any layout prop takes one value per breakpoint:
Grid(columns: { base: 1, md: 2, lg: 3 }, gap: { base: .sm, lg: .lg }) { … }
Row(direction: { base: .column, md: .row }) { … }
Column(span: { base: 12, md: 6 }) { … }
That compiles to one class and one media query per step — no JavaScript, no
resize listener, and the right layout in the very first paint, where a
viewport test could only paint one branch and swap after hydration.
The steps are base, sm, md, lg, xl, and each is a design token —
screen-sm … screen-xl. Move one in your theme and every media query
moves with it, including viewport.md:
theme Brand { screen-md: 900px }
A step a value omits keeps whatever the step below it said, so
{ base: 1, lg: 3 } is one column until lg.
The same names are sugar inside a style { } block:
Heading("Title").h1 {
style {
font-size: 24px
@md { font-size: 34px }
}
}
The engine used to reflow every Row into a column and every
Grid into one column below 768px, with !important. It does not any
more. A layout that should reflow says so:
Row.stacks { … } // a column on a narrow screen
Grid.stacks { … } // one column on a narrow screen
Grid(columns: { base: 1, md: 3 }) { … } // or say what it does at each width
wf migrate adds .stacks to every Row, Grid and Column of a
WebFluent 2 project, so the old behaviour carries over — written down,
where you can see it and change it.
viewport (chapter 8)
stays for what is genuinely behavioural — a drawer instead of a sidebar —
and follows matchMedia, which fires once when the answer changes rather
than on every pixel of a drag.
styles.css holds the tokens something in the output uses
(:root { --color-primary: … }), the dark overrides, the CSS of the built-ins
you used (and only those), your bundled stylesheets, and the compiled
style { } rules that more than one page reaches. A rule only one page can
reach goes to pages/<Name>.css, loaded with that page.
Performance shows how to see what each weighs.