Theming
Change the brand colours, switch between light and dark, set density, and see exactly what createTheme guarantees.
@azmr/ui is themed with CSS custom properties: the design tokens. You can override a token in plain CSS, or call createTheme to derive a whole accessible set from your brand colours. The examples with an // => line on this page are run by the docs tests, so the output shown is what the package returns.
Change the brand colour with createTheme
import { createTheme } from "@azmr/ui/theme";
const theme = createTheme({ primary: "#1E88E5", radius: "0.5rem" });
const changed = Object.keys(theme.cssVariables);
// => ["--primary","--primary-foreground","--ring","--sidebar-primary","--sidebar-primary-foreground","--sidebar-ring","--radius"]Setting primary sets six variables: the colour, the text on it, the focus ring, and the same three for the sidebar. Each colour value is a light-dark() pair, so it follows the page's mode. These are the options:
Prop
Type
Every option is optional and keeps its default when left out. Colours are #rgb, #rrggbb, oklch(L C H) or { l, c, h }.
What it guarantees
The colour you pass is a starting point. createTheme moves its lightness, and picks white or near-black text, until these hold in both light and dark mode:
- Each
-foregroundis at least 4.5:1 on its colour, including on a solid button's hover surface. A button blends with the page on hover, which can pull a colour that only just passes below 4.5:1, so the derivation checks the blended colour too. success,infoanddestructiveare also readable as text on the page and on cards (Text tone, form errors).warningis a yellow surface, so it gets a separate, darker--warning-textfor text.- The focus ring is at least 3:1 on the page and on cards.
Because of this, the result can differ from your input. The light-mode primary above is darker than #1E88E5, so that white text on it stays readable when the pointer is over it. A very light brand colour such as #FFEB3B goes the other way and gets dark text:
const theme = createTheme({ primary: "#FFEB3B" });
const foreground = theme.cssVariables["--primary-foreground"];
// => "light-dark(oklch(0.145 0 0), oklch(0.145 0 0))"Print theme.cssVariables to see what you get. The package's tests check these ratios for a range of brand colours, and the token reference lists the ratios for the default tokens.
Invalid input
createTheme validates every option and throws an Error that names it, because its output is CSS:
let message;
try {
createTheme({ primary: "blue" });
} catch (error) {
message = error.message;
}
// => "createTheme: primary must be #rgb, #rrggbb, oklch(L C H) or { l, c, h }; got \"blue\""radius must be a number in rem or px, font must be a short font-family list (letters, digits, spaces, commas, quotes, dots, hyphens and underscores, 200 characters at most), and density must be compact, comfortable or spacious.
Apply the theme
A theme is a set of custom properties, so apply it to <html>, or to any element to theme only what is inside it:
import { createTheme } from "@azmr/ui/theme";
const theme = createTheme({ primary: "#1E88E5", destructive: "#D32F2F" });
// Whole app: the variables as a style attribute, density as data-density.
<html lang="en-NZ" style={theme.cssVariables} {...theme.htmlAttributes}>
// One section only.
<section style={createTheme({ primary: "#7B1FA2" }).cssVariables}>…</section>You can also emit the variables as CSS:
const theme = createTheme({ radius: "0.5rem", font: "Inter, sans-serif" });
const css = theme.css;
// => ":root {\n --radius: 0.5rem;\n --font-sans-stack: Inter, sans-serif;\n}"Prefer the style attribute: an inline style always wins. If you output theme.css in a <style> tag, put it after the @azmr/ui stylesheet. The tokens' own scopes (.dark, [data-theme]) have the same specificity as :root, so for the same element the later rule wins.
A theme's colours set on an outer element do not reach inside a nested data-theme or .dark scope, because that scope re-declares every colour token. Put the theme's variables on the scope element itself, or inside it.
To change one token by hand, override it for light and for dark:
:root {
--primary: oklch(0.55 0.2 260);
}
.dark,
[data-theme="dark"] {
--primary: oklch(0.75 0.15 260);
}Dark mode
By default the page follows the operating system. The colours use OKLCH with light-dark() and color-scheme: light dark, so there is no JavaScript and no flash.
To force a mode, put class="dark" or class="light", or data-theme="dark" or data-theme="light", on <html> or on any element. Scopes nest: a data-theme="light" panel inside a dark page is light.
<html data-theme="dark">
<body>
<aside data-theme="light">Always light, even on a dark page.</aside>
</body>
</html>Each scope declares every colour token explicitly instead of relying on light-dark() alone. Some bundlers (Next.js does, for older browser targets) rewrite light-dark() into variables that resolve where they are declared, which would stop a nested scope from changing anything. Declaring the colours in each scope works however the CSS is processed, and a test keeps those copies identical to :root.
Remember the choice without a flash
Store the preference in a cookie and render it into <html> on the server, so the first paint is already right. This needs no inline script, so it works under a strict Content-Security-Policy:
import { htmlAttributes } from "@azmr/ui/theme";
const attributes = htmlAttributes.fromCookies("azmr-theme=dark; azmr-density=compact");
// => {"data-theme":"dark","data-density":"compact"}Only known values are accepted. Anything else in the cookie is ignored, and system needs no attribute at all:
const ignored = htmlAttributes.fromCookies("azmr-theme=evil; azmr-density=huge");
// => {}ThemeProvider and useTheme() change the theme and density on the client and write the cookie; see Theme, density and locale for a Next.js layout. A static site that cannot read cookies on the server can inline themeInitScript() instead; give the <script> tag your CSP nonce.
Density
Density changes control heights, horizontal padding, gaps and row height together. Set data-density="compact" or data-density="spacious" on <html> or on any element; comfortable is the default and needs no attribute.
<div data-density="compact">
<!-- Buttons, inputs and table rows in here are compact. -->
</div>The token reference lists the four values at each level. createTheme can set it too, through htmlAttributes:
const compact = createTheme({ density: "compact" }).htmlAttributes;
const comfortable = createTheme({ density: "comfortable" }).htmlAttributes;
const attributes = { compact, comfortable };
// => {"compact":{"data-density":"compact"},"comfortable":{}}Reduced motion
When the operating system asks for reduced motion (prefers-reduced-motion: reduce), every --motion-duration-* token collapses to 0.01ms and the stagger step to 0ms. A safety net also shortens other animations and transitions on the page. Reduced motion follows the system setting; the tokens have no per-subtree switch.
Mark motion that is genuinely essential, such as a spinner that shows work is happening, with data-motion="essential" to keep it. See Motion for the motion tokens and what each component does.