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:
suppressHydrationWarningis 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
:rootwill be wrong in dark mode, and it will be wrong quietly. - Set
color-schemeon 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"withenableSystem: 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,
RootProvideris it, and shadcn'stheme-provider.tsxis deleted deliberately. - Do not use
prefers-color-schemealone. 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
#fffor#000anywhere. 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
suppressHydrationWarningproduces a hydration error in development that looks like a bug in your code. It is the theme script doing its job. - Two
@custom-variant darkdeclarations: last one wins, silently. If dark utilities stop applying after adding a dependency, look for a second declaration. localStoragethrows 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
#eeedisappears entirely. color-schemeon a nested element does not cascade the way you expect to form controls that portal out of it.
Related
- Dark mode (Foundations) explains why the dark palette is derived rather than inverted.
- Tailwind v4 covers the import order and the variant declaration.
- Contrast and APCA shows how the floor is measured in both themes.
- Increased contrast covers the third media query, and what it changes that dark mode does not.
Icons
Bring your own icon set. This page covers the slot contract, the sizing rule, and the hard prohibition on an icon carrying clinical status by itself.
Server and client components
Where the client boundary falls, what is safe to render on the server, and why importing one interactive component can turn a whole page into client code.