Foundations
Theming and colours
Components never name a colour: they use semantic tokens (surface, primary, danger). A theme is new values for those tokens; dark mode is a second set.
The tokens#
Live, from this page. Switch light and dark in the header and watch the values change.
Surfaces
Lines and ink
Brand
Intent
Beyond colour
- Radius:
--r-ctl(controls, 8px),--r-card(cards, 10px),--r-dlg(dialogs, 14px). - Elevation:
--elev2,--elev3, and--lit, the one-pixel top light that dark surfaces get. - Density:
--row-h,--ctl-h,--cell-px,--panel-item, switched bydata-densityon<html>. - Motion:
--durand--ease; everything stops for people who ask for reduced motion.
In Tailwind they are utilities: bg-surface, text-muted, border-border, bg-primary-subtle, rounded-card, shadow-elev3, h-ctl. Use them in your own screens and they follow the theme too.
Making it yours#
A few values by hand
/* globals.css, after @import "@waveinno/ui/styles.css" */
:root {
--primary: #0f9d7a;
--r-card: 14px;
}
[data-theme="dark"] {
--primary: #32b08c;
}A whole theme from one colour
createTheme() derives the hover step, tints, the brand gradient, the ink and a dark set from one brand colour, in OKLCH so every hue gets even steps. A colour too light for white text is darkened until it can carry it.
import { createTheme } from "@waveinno/ui";
const theme = createTheme({
primary: "#0f9d7a", // the brand colour; everything else is derived
radius: "lg", // none | sm | md | lg | xl
fontSans: "Manrope", // optional: body, display and mono families
});
theme.css; // ":root { … } [data-theme=\"dark\"] { … }", paste into globals.css
theme.light; // { "--primary": "#00906f", … }, for a style prop
theme.dark;For #0f9d7a with a large radius it writes:
:root {
--primary: #00906f;
--primary-hover: #00785c;
--primary-subtle: #dff9ee;
--primary-muted: #b2e0ce;
--accent: #00929a;
--info: #00906f;
--info-subtle: #dff9ee;
--selected: #daf8ec;
--brand-1: #008454;
--brand-2: #00a7aa;
--ink: #001c13;
--radius-scale: 1.4;
--r-ctl: 11px;
--r-card: 14px;
--r-dlg: 20px;
}
[data-theme="dark"] {
--primary: #32b08c;
--primary-hover: #4ec6a1;
--primary-subtle: rgba(50, 176, 140, 0.16);
--primary-muted: rgba(97, 215, 177, 0.38);
--accent: #3db1b9;
--info: #32b08c;
--info-subtle: rgba(50, 176, 140, 0.14);
--selected: rgba(50, 176, 140, 0.16);
}At runtime, or for one part of a page
"use client";
import { createTheme } from "@waveinno/ui";
/** A theme per tenant, chosen at runtime: inject its CSS once. */
export function TenantTheme({ brand }: { brand: string }) {
return <style>{createTheme({ primary: brand }).css}</style>;
}// Tokens are CSS variables, so a theme can apply to one part of a page.
const { light } = createTheme({ primary: "#e11d48" });
<section style={light as React.CSSProperties}>
<Button variant="primary">Rose, here only</Button>
</section>Dark mode and density#
The theme is the data-theme attribute on <html> (light or dark); density is data-density (compact, default, comfortable). themeBootScript() sets both before first paint from what the user chose last. Until they choose, it follows the device's setting; to always start light (or dark), pass it second: themeBootScript("default", "light").
import { DensityToggle, ThemeToggle, useUi } from "@waveinno/ui";
<ThemeToggle /> // the sun / moon switch
<DensityToggle /> // S / M / L
const theme = useUi((s) => s.theme); // "light" | "dark"
const setTheme = useUi((s) => s.setTheme); // saved, and applied to <html> at once