opsinjs
HandbookEnvironment

Dark mode

One class on the root element, one provider, and the two-line inline script that prevents the flash. Dark mode is a contrast problem before a colour problem.

The short version

Dark mode is a dark class on the root element. Everything else follows from CSS custom properties redefined under that class:

:root { --opsin-surface: oklch(99% 0 0); }
.dark { --opsin-surface: oklch(18% 0.01 260); }

There is exactly one theme provider in an opsinjs application. In this documentation site it is fumadocs' RootProvider, which mounts next-themes internally. Adding a second ThemeProvider produces two sources of truth for the same class and a toggle that works only sometimes.

How it works

The class strategy

A class on the root beats a media query for one decisive reason: a media query cannot be overridden by the reader. With a class, three states exist: light, dark, and follow the system. The reader chooses between them instead of being handed whichever one their operating system decided on. Which of the three a reader starts on is a separate decision, taken below; this site starts on light.

The Tailwind v4 variant is declared once:

@custom-variant dark (&:where(.dark, .dark *));

The :where() form contributes zero specificity, which keeps dark-mode utilities from quietly winning specificity fights they were not meant to win. This declaration is pinned as the last line of globals.css, because both shadcn and fumadocs declare their own version of the variant and the last one wins silently. Pinning the superset at the bottom makes the file order-independent for that one rule. See Tailwind v4.

Preventing the flash

Theme preference lives in localStorage, which the server cannot read, so the first paint would be light and then correct itself. That self-correction is a visible flash on every navigation for readers who chose dark. next-themes solves it with a small blocking script injected before paint that reads the stored value and sets the class. Two consequences:

  • suppressHydrationWarning is required on the <html> element, because the server rendered no class and the client added one before React hydrated. This is the one legitimate use of that prop.
  • The script must be blocking. Deferring it reintroduces the flash.

Dark is not inverted light

The colour engine derives dark values in OKLCH rather than flipping lightness. Two reasons that matter in a health product: pure black backgrounds cause noticeable smearing on OLED during scroll, and chroma behaves differently against a dark ground. A status colour that is clearly distinguishable on white can collapse toward its neighbours on near-black. Every token pair is measured in both themes and the numbers are generated, not asserted; see Contrast conformance.

Do this

  • Define every colour as a token in both themes. A value defined only under :root will be wrong in dark mode, and it will be wrong quietly.
  • Set color-scheme on the root so form controls, scrollbars and the browser's own UI follow. It is one declaration and it fixes the white scrollbar nobody can find the cause of.
  • Default to light, and offer light, dark and "system" as explicit choices. This site ships defaultTheme: "light" with enableSystem: true, because a health surface is read outdoors and printed for review, and a reader who has expressed no preference is better served by the theme the contrast floor was authored against. The reasoning is in Dark mode. Offering "system" still matters: it is how a reader delegates the choice, and it is the option a reader who has already set an OS preference expects to find.
  • Check status colours in both themes at the contrast floor, not by eye. The four status levels must remain distinguishable from each other, not merely legible against the background.
  • Test the toggle on a page with a chart and a material surface. Those are where dark mode breaks first.

Not this

  • Do not mount a second theme provider. One only. In this site, RootProvider is it, and shadcn's theme-provider.tsx is deleted deliberately.
  • Do not use prefers-color-scheme alone. It removes the reader's ability to choose, and a reader whose system is dark but who needs a light interface for contrast reasons has no way out.
  • Do not hardcode #fff or #000 anywhere. Both are wrong in one of the two themes, and pure black is wrong in dark mode as well.
  • Do not lower opacity to make something look "dim" in dark mode. It reduces contrast against an already-dark ground, which is the exact failure the contrast floor exists to prevent. Use a token.
  • Do not animate the theme change. A cross-fade of the entire page is a large motion event, and it is the first thing to go under prefers-reduced-motion.

Gotchas

  • A missing suppressHydrationWarning produces a hydration error in development that looks like a bug in your code. It is the theme script doing its job.
  • Two @custom-variant dark declarations: last one wins, silently. If dark utilities stop applying after adding a dependency, look for a second declaration.
  • localStorage throws in some contexts, such as private windows with site data blocked and some embedded webviews. Wrap reads and writes, and render correctly with no stored value.
  • An iframe does not inherit the parent's class. Component previews rendering in a (view) route need the theme passed through explicitly; see Lyra and the docs chrome.
  • Images and charts need their own dark treatment. A screenshot with a white background in a dark page is a bright rectangle; a chart whose gridlines are #eee disappears entirely.
  • color-scheme on a nested element does not cascade the way you expect to form controls that portal out of it.

On this page