Theming
Generate a theme from one color in CSS
With Monsoon a theme is one line: --seed: #0d9488. The browser derives every semantic color from it with OKLCH relative color syntax, including foregrounds that keep readable contrast. No preprocessor, no generator website, no JavaScript.
Set the seed
Any valid CSS color works: a hex value, a named color, rgb(), hsl() or oklch(). Put it on the root element to theme the whole page:
:root {
--seed: #0d9488;
}Or skip the stylesheet and use an attribute. Any element with --seed in its style attribute recomputes the tokens for its subtree:
<section style="--seed: teal" class="rounded-2xl bg-surface p-8 space-y-3">
<h2 class="text-2xl font-bold text-primary">Teal section</h2>
<button class="rounded-lg bg-primary text-primary-foreground px-4 py-2">Primary</button>
<button class="rounded-lg bg-accent text-accent-foreground px-4 py-2">Accent</button>
</section>teal
#e11d48
gold
Notice the gold card: a light seed gets dark text on its buttons, a darker seed gets white text. That flip is automatic.
The tokens you get
Every token is available as a utility for text, background, border, ring and more (bg-primary, text-muted-foreground, border-border) and as a custom property (var(--primary)) for your own CSS.
| Token | Derived as |
|---|---|
primary, primary-foreground | The seed with lightness pushed out of the unsafe band, and white or near black text. |
secondary, secondary-foreground | A low chroma tint of the seed for quiet buttons. |
accent, accent-foreground | The seed hue turned by 180 degrees, kept light. |
background, foreground | Near white and near black in light mode, swapped in dark mode, with a hint of the seed hue. |
surface, surface-2, surface-3 | Raised layers for cards and panels. |
muted, muted-foreground | Subtle fills and secondary text. |
border, ring | Hairlines and the focus ring. |
success, warning, danger, info | Fixed status hues with their own foregrounds. |
How it works: OKLCH relative colors
Relative color syntax reads the channels of an existing color and lets you compute a new one from them. In OKLCH the channels are lightness l, chroma c and hue h, and lightness tracks how bright a color looks to people. That makes it the right space to reason about contrast. This is the real declaration for the background token:
--background: oklch(from var(--seed) calc(0.99 - 0.83 * var(--mode)) min(c, 0.012) h);It keeps the hue of your seed, caps the chroma so the background stays almost neutral, and picks a lightness of 0.99 in light mode or 0.16 in dark mode. --mode is a number, 0 or 1, which is how one declaration serves both schemes.
The primary color and its foreground
White text needs a dark fill and dark text needs a light fill. Around a lightness of 0.52 to 0.66 neither choice reaches a 4.5:1 contrast ratio for every hue, so Monsoon moves the primary color out of that band, to whichever side is closer to your seed. The step is written with clamp() and a large multiplier, a branch free "if" that works inside calc():
--primary: oklch(from var(--_seed)
calc((1 - clamp(0, (l - 0.6) * 100000, 1)) * min(l, 0.52)
+ clamp(0, (l - 0.6) * 100000, 1) * max(l, 0.66)) c h);
--primary-foreground: oklch(from var(--primary)
clamp(0.17, (0.6 - l) * 100000, 1) min(c, 0.03) h);The foreground is white when the primary is dark and near black when it is light. The accessible contrast guide shows how this is tested.
Per section themes and the theme class
Custom properties are resolved where they are declared. That is why Monsoon redeclares the tokens on :root, on .dark, .light, [data-theme] and on any element whose style attribute contains --seed. If you set the seed from your own stylesheet on some other element, add the theme class to it so the tokens are recomputed there:
[data-brand="orange"] { --seed: #ea580c; }<aside data-brand="orange" class="theme rounded-xl bg-surface p-6">
<a class="text-primary underline">Orange link</a>
</aside>Older browsers
Relative color syntax is supported in current versions of Chrome, Edge, Firefox and Safari. For older browsers Monsoon ships a static fallback inside @supports not (color: oklch(from red l c h)): the tokens fall back to values computed from the default seed, primary and ring use your --seed directly, and the tokens that only depend on the color scheme still switch between light and dark.
In practice that means a visitor on an old browser still gets a coherent, readable page in the default palette with your seed on primary buttons and links. Nothing breaks and no script is needed to detect support.
Frequently asked questions
Which color formats can I use for --seed?
Any CSS color: hex, named colors, rgb(), hsl(), oklch() and others. The browser converts it to OKLCH for the calculations.
Can users pick their own theme color?
Yes. Write their color into a style="--seed: ..." attribute from your server, or set it with one line of JavaScript on the root element. Everything else follows.
Do I have to use the generated colors?
No. The 22 hue palette is available too, as bg-blue-600, text-rose-500 and so on. Semantic tokens are recommended because they adapt to dark mode and to the seed.
Does it work without JavaScript?
Yes. The theme is computed by the CSS engine. JavaScript is only needed if you want to change the seed interactively.
Try it in ten seconds
Paste this into any HTML page. No install, no build.
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/monsooncss">