Theming
A theme is a point in an eight-channel space. The engine derives every token from it, so radius, depth, motion and type change together and stay coherent.
Apply a theme
Two attributes do all of it. data-theme picks the character and data-mode picks light or dark. Without either, you get graphite in light mode.
<!-- The whole document, light or dark as the reader's device is set -->
<html data-theme="graphite" data-mode="system">
<!-- Or any subtree, in a fixed mode: themes nest -->
<section data-theme="atelier" data-mode="dark">…</section>Canonical themes
- GraphiteDefault
data-theme="graphite"Neutral graphite, crisp hairlines, and ink that stays out of the way.
- Atelier Umami
data-theme="atelier"Warm cotton paper, artisanal ink, and an amber signal.
- Machined Titanium
data-theme="machined"Aerospace telemetry, compact precision, and high contrast.
- Aerogel Glass
data-theme="aerogel"Optical luminescence, liquid refraction, and specular depth.
- Nordic Earth
data-theme="nordic"Forest moss, fjord stone, terracotta, and organic calm.
The eight channels
Each channel is a number from 0 to 1. Moving one changes several tokens at once.
| Channel | What it moves |
|---|---|
type | utilitarian ↔ editorial: display weight, tracking, leading |
geometry | rectilinear ↔ organic: radius and border weight |
density | compact ↔ spacious: body line height |
depth | flat ↔ layered: shadow offset, blur and strength |
motion | still ↔ kinetic: durations and spring damping |
texture | polished ↔ tactile: grain and border presence |
rhythm | regular ↔ syncopated: tracking, timing and leading couplings |
icon | systematic ↔ expressive: glyph stroke weight |
Your own theme
The quickest start is your brand color: pick it on the home page and this whole site, every theme and every template preview, puts it on, solved for contrast in light and dark. Copy the theme file it gives you and build it as below.
Write mlola.theme.json at the project root. Start from a canonical theme and change only what differs; every key is optional, and unknown keys are ignored so a theme written today keeps working as the engine grows.
{
"$schema": "./packages/engine/generated/theme-spec.schema.json",
"id": "acme",
"label": "Acme",
"//": "A theme is a few decisions. The engine derives every token and solves contrast in both modes. Every key is optional.",
"inherit": "nordic",
"//vector": "Eight channels from 0 to 1. Each one moves geometry, rhythm, depth and motion together.",
"vector": {
"type": 0.7,
"geometry": 0.35,
"density": 0.45,
"depth": 0.3,
"motion": 0.4,
"texture": 0.2,
"rhythm": 0.5,
"icon": 0.6
},
"//color": "The primary seed keeps its hue and chroma; its lightness is adjusted only as far as contrast demands. primaryForeground picks the label on primary buttons in light mode: auto, light or dark.",
"color": {
"primary": "oklch(0.55 0.19 265)",
"primaryDark": "oklch(0.72 0.17 265)",
"primaryForeground": "auto",
"neutral": {
"hue": 265,
"chroma": 0.006
}
},
"//material": "solid, glass, paper or anodized. Applied to every surface.",
"material": "solid",
"//fonts": "A named set (neutral, editorial, technical, humanist, geometric) or explicit stacks.",
"fonts": {
"sans": "\"Inter Tight\", ui-sans-serif, system-ui, sans-serif",
"display": "\"Fraunces\", Georgia, serif",
"mono": "\"JetBrains Mono\", ui-monospace, monospace"
},
"//scale": "Override any step of the spacing or type scale without touching the rest.",
"scale": {
"space-4": "1.0625rem",
"type-base": "0.9375rem"
},
"//extend": "Raw custom properties. The escape hatch for anything the engine has not modelled yet.",
"extend": {
"--acme-brand-gradient": "linear-gradient(120deg, oklch(0.55 0.19 265), oklch(0.72 0.17 200))"
}
}Render it with the engine installed in your project. It works offline and writes styles/mlola/theme.css beside the canonical themes, on the same data-theme contract, so nothing about using it is special. Run it again after every change to the file.
npx mlola-ui theme buildThen set "theme" in mlola.config.json and data-theme on the root to the theme's id. A theme made in the Studio arrives the same way with npx mlola-ui theme pull, which writes both files.
The label on primary buttons
The engine picks white or dark ink for primary buttons, whichever reads better, and moves the fill only as far as contrast needs. Red, burnt orange, green, blue and violet carry white. Amber, yellow and a bright orange carry dark ink, because white on them fails contrast. So a light seed can come back with dark text on its buttons.
To keep white, choose a deeper seed: an OKLCH lightness of about 0.6 or lower carries white without moving. Or ask for it with color.primaryForeground: "light" deepens the fill until white text passes, and "dark" lightens it for dark ink. Either way contrast still holds. This applies in light mode only. At night the engine still chooses, because a fill dark enough for white would sink into the page.