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

--canvasThe page
--surfacePanels, wells, table heads
--raisedCards, menus, dialogs
--hoverHovered rows and items
--selectedSelected rows

Lines and ink

--borderHairlines
--border2Field borders
--textHeadings, values
--bodyRunning text
--mutedLabels, hints

Brand

--primaryButtons, links, the chosen item
--primary-hoverTheir hover
--primary-subtleTinted fills
--brand-1Gradient start
--brand-2Gradient end
--inkDark panels, bubbles

Intent

--successDone, paid, online
--warningNeeds a look
--dangerFailed, destructive
--infoNeutral news

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 by data-density on <html>.
  • Motion: --dur and --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
/* 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.

theme.ts
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:

theme.css
: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

tenant-theme.tsx
"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>;
}
scoped.tsx
// 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").

anywhere.tsx
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