styled-ppx is a compile-time CSS system. This page explains what happens between writing [%css "display: flex"] and a browser painting your page.
your .re / .ml source
│
▼
① ppx expansion (while compiling each file)
─ parse & type-check the CSS
─ split into atomic rules, one class per declaration
─ lower $(expr) interpolations to CSS custom properties
─ park the rendered CSS in [@@@css "..."] attributes
│
▼
② dune builds your project normally
│
▼
③ styled-ppx.generate (a dune rule you add once)
─ harvests the [@@@css ...] attributes from every compiled file
─ resolves cross-module class references
─ deduplicates and writes a single styles.css
│
▼
styles.css — a plain, static asset you link like any stylesheet
Your components never construct CSS at runtime. They only carry the class names the ppx minted at step ①, plus — when you use interpolation — the values of a few CSS custom properties.
See Setting up the dune rules for the concrete build configuration.
Every declaration inside a [%css] or [%styled.<tag>] block becomes its own rule with its own class:
let layout = [%css {|
display: flex;
align-items: center;
|}];
extracts to:
.css-k008qs-layout { display: flex; }
.css-1tyndxa-layout { align-items: center; }
and layout’s className is the space-separated list: "css-k008qs-layout css-1tyndxa-layout".
Class names are content-addressed: the hash is derived from the CSS text itself. Two components declaring display: flex anywhere in your project share the exact same rule, and the aggregator emits it once. On large apps this makes the stylesheet grow with the number of distinct declarations, not the number of components — the same effect utility-CSS frameworks achieve, without you maintaining a naming convention.
Repeated fallback declarations for the same property (e.g. display: -webkit-box; display: flex;) are kept together in a single atom, so their intra-rule order — which the fallback depends on — is preserved.
The format is css-<hash>-<label>, where the label is the let binding or module name (layout above). The label is purely cosmetic, for debuggability: hashes alone determine identity and deduplication. Running the ppx with the --minify flag drops the label, emitting bare css-<hash> names for production.
All atoms have the same specificity (a single class). The tiebreaker is stylesheet order — later wins — and the aggregator preserves your source order exactly. Writing
let spaced = [%css {|
margin: 10px;
margin-top: 20px;
|}];
guarantees the margin-top atom appears after the margin atom in styles.css, so the override behaves like it would in a plain stylesheet.
Interpolating a value does not opt a block out of extraction. The ppx lowers $(expr) to a CSS custom property in the extracted rule:
let title = (~color) => [%css {|
font-size: 2rem;
color: $(color);
|}];
.css-abc123-title { font-size: 2rem; }
.css-def456-title { color: var(--color-xk29fh); }
The runtime side only supplies the property’s value, through the element’s inline style attribute (style="--color-xk29fh: #667eea"). That’s why elements styled with interpolations carry a small style attribute: it holds variable definitions, never declarations.
A few details worth knowing:
--<source-name>-<hash>, identical in dev and prod, so $(Theme.spacing.md) becomes --spacing-md-<hash>.toString for the property it’s used in, so a Color.t where a length is expected is a compile error.@property { inherits: false }, so updating one only recalculates that element’s style, not its whole subtree.[%styled.global], interpolated values are supplied through a :root { ... } block rendered by the generated component instead of inline styles.Interpolating a binding in selector position (&.$(active), :has(.$(other))) is resolved statically — the actual class names are substituted into the extracted CSS, with no var() indirection:
let active = [%css "color: red"];
let button = [%css {|
opacity: 0.8;
&.$(active) { opacity: 1; }
|}];
.css-xxx-active { color: red; }
.css-yyy-button { opacity: 0.8; }
.css-yyy-button.css-xxx-active { opacity: 1; }
Same-file references resolve during ppx expansion. References to bindings in other modules are resolved by the aggregator at stylesheet-generation time — this is one of the reasons the aggregator exists. Referencing a binding that doesn’t exist is a hard error with the original source location, and cross-library references are rejected.
styled-ppx.generateThe ppx runs one file at a time and cannot see the rest of your project, so each compiled file carries its extracted CSS in top-level attributes ([@@@css "..."]) that ride along in the build artifacts. styled-ppx.generate is a small CLI that:
.ml/.pp.ml files you point it at,styled-ppx.generate --output styles.css --log info <files...>
| Flag | Effect |
|---|---|
-o, --output <file> | write the stylesheet to a file (default: stdout) |
--log error|warning|info|debug | diagnostic verbosity (default: error) |
--minify | strip inter-rule newlines |
You wire it up once as a dune rule — see the dune setup guide.
<style> tags as components render (the single exception: [%styled.global] renders one <style> element for :root variable values, when you interpolate in globals).styles.css in your <head> and render.styles.css is a static asset with a stable content — it caches like any other file and can be served from a CDN.