Monsoon v0.2.0
Made by Alexandre Kohler Star

Getting started

Dark mode

If your page uses the semantic colors, dark mode already works. Monsoon follows the operating system setting, and one class or attribute forces a scheme on the whole page or on any section. JavaScript is only needed if you want a switcher.

4 min read

Automatic by default

Monsoon listens to prefers-color-scheme. When the visitor uses a dark system theme, the number --mode becomes 1 and every semantic token recomputes: the background goes dark, text goes light, surfaces and borders follow, and the primary color is lifted so it stays readable on the dark background. Monsoon also sets color-scheme, so native form controls and scrollbars switch with it.

This card is correct in both schemes without a single dark: class:

card.html
<div class="rounded-2xl border bg-surface p-6 space-y-3">
  <h3 class="text-lg font-semibold">Invoices</h3>
  <p class="text-muted-foreground">Three are waiting for approval.</p>
  <a href="/invoices" class="text-primary font-medium underline underline-offset-4">Review now</a>
</div>

Force a scheme on the page

To pin the whole page, add a class or an attribute to the <html> element. The class and the attribute are equivalent:

index.html
<html lang="en" class="dark">
<!-- or -->
<html lang="en" data-theme="dark">

class="light" or data-theme="light" pins light mode, which also stops the automatic switch when the operating system is dark. Without either, the page follows the system.

Dark and light sections

The same classes work on any element. A themed section recomputes the tokens for everything inside it, switches color-scheme, and paints its own text and background color, so a light card inside a dark page is never dark text on a dark backdrop:

sections.html
<div class="light rounded-xl border p-5">...</div>
<div class="dark rounded-xl border p-5">...</div>
A light and a dark card side by side

class="light"

Same markup, light tokens.

Draft

class="dark"

Same markup, dark tokens.

Draft

The automatic paint has zero specificity, so a background or text utility on the same element still wins. Use theme instead when you only want new tokens for a different --seed, without any paint.

Nested sections

Sections nest to any depth. --mode is inherited, so the nearest themed ancestor decides, and the tokens are exact at every level:

Dark, light, dark

class="dark"

class="light" inside a dark section

class="dark" one level deeper

The dark: variant follows the same nearest ancestor rule. A selector cannot express "nearest" at any depth by itself, so the deepest case (dark inside light inside dark) relies on @scope. In browsers without @scope, dark: classes in that one arrangement keep their light values. Semantic tokens are not affected.

The dark: variant for palette colors

Semantic tokens switch by themselves. Palette colors do not, because bg-slate-100 is one specific color. For those, Monsoon generates a dark: variant:

  • every step of the neutral hues (slate, gray, zinc, neutral, stone) for background, text and border,
  • the middle steps of the other hues: 300 to 700 for text, 500 to 700 for background and border,
  • white, black and transparent, and the opacity modifiers such as bg-black/10 and bg-primary/90.
badge.html
<span class="rounded-full bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-200 px-3 py-1 text-sm">
  Draft
</span>
<div class="rounded-xl border border-black/10 dark:border-white/10 p-4">...</div>

dark: applies inside .dark or [data-theme=dark], and with the system dark setting unless the page or a nearer section is forced light. Filter the class reference by variant to see every class that has it.

A theme switcher: system, light, dark

A three state switcher stores the choice and writes data-theme on the <html> element. "System" simply removes the attribute, so the page follows the operating system again.

Step 1: apply the saved choice before the first paint. Put this inline script in the <head>, before the stylesheet link. It runs before anything is drawn, so a visitor who chose dark never sees a flash of the light page. Keep it inline: an external or deferred script would run too late.

head.html
<script>
  // Runs before the first paint: apply a saved choice, if any.
  (function () {
    try {
      var theme = localStorage.getItem('theme');
      if (theme === 'light' || theme === 'dark') {
        document.documentElement.setAttribute('data-theme', theme);
      }
    } catch (e) {}
  })();
</script>

Step 2: the control. A native <select> is keyboard and screen reader friendly with no extra work:

theme-picker.html
<label class="inline-flex items-center gap-2 text-sm font-medium">
  Theme
  <select data-theme-picker class="rounded-lg border bg-surface px-3 py-2 text-sm">
    <option value="system">System</option>
    <option value="light">Light</option>
    <option value="dark">Dark</option>
  </select>
</label>

<script>
  (function () {
    var root = document.documentElement;
    var picker = document.querySelector('[data-theme-picker]');
    picker.value = root.getAttribute('data-theme') || 'system';

    picker.addEventListener('change', function () {
      var choice = picker.value;
      if (choice === 'system') root.removeAttribute('data-theme');
      else root.setAttribute('data-theme', choice);
      try {
        if (choice === 'system') localStorage.removeItem('theme');
        else localStorage.setItem('theme', choice);
      } catch (e) {}
    });
  })();
</script>

The try blocks matter: some browsers throw when storage is disabled, and the page should still work. If you prefer classes, use root.classList with dark and light instead of the attribute; Monsoon treats both the same.

On a server rendered site you can skip the head script: store the choice in a cookie and render data-theme="dark" on the <html> element directly. The page is correct on the first paint with no script at all.

Details worth a second look

  • Shadows are hard to see on dark surfaces. Separate layers with bg-surface-2 or bg-surface-3 and a border rather than a large shadow.
  • Logos and illustrations with dark strokes can disappear. Swap them with a <picture> element and a prefers-color-scheme media query, or give them a light backing.
  • Pure white text on a dark background can glare. foreground is a soft off white, so prefer text-foreground over text-white for body copy.
  • Charts drawn with palette colors keep their colors. Middle steps such as 500 read well on both backgrounds.
  • Your own CSS follows the scheme for free when it uses tokens: border-color: var(--border) is right in both modes, with no media query.

Frequently asked questions

Do I need dark: classes for dark mode?

Not for semantic colors such as bg-surface, text-foreground or bg-primary, which switch on their own. Use dark: only for fixed palette colors, white, black and opacity modifiers.

Should I use class="dark" or data-theme="dark"?

Either. They do exactly the same thing. Pick the one that fits your templates or your switcher script.

How do I stop the flash of the wrong theme on load?

Apply the saved choice with a small inline script in the <head>, as shown above, or render the attribute on the server.

Can one section stay dark while the page is light?

Yes. Put class="dark" on the section. Tokens are recomputed for everything inside it.

No recent searches

Try , , or .

Recent

Loading the search index

No results for ""

Check the spelling, or search a class name such as px-4.