Skip to main content
11 min read

How to Prevent Theme Flashes in a Design System

A visitor chooses dark mode, refreshes the page, and sees the wrong colors for a moment. The application eventually looks correct, but the first impression is already inconsistent.

For a simple page, restoring the saved preference before the first paint may solve this. A design system introduces another layer: sections can inherit the page's mode, deliberately invert it, or keep their own mode. The page can be correct while one of those sections still flashes.

The challenge is making the server-rendered HTML, the browser's preference, and the component state agree early enough. This article walks through an approach you can apply to your own system, using a theme bug we encountered in Vortex as the worked example.

Find which layer changes after the first paint

Start by identifying what is actually wrong. A whole page changing from light to dark is a different failure from a dark page whose footer briefly uses the wrong palette.

Inspect three moments: the HTML returned by the server, the DOM after any preference bootstrap has run, and the DOM after hydration. Record both the root mode and the attributes on the affected section. Check computed colors as well as attribute values; a correct attribute does not guarantee that the intended stylesheet won the cascade.

For an initial investigation, ask:

  • Does the server know the visitor's preference, or is it rendering a default?
  • Is the preference applied before visible content can paint?
  • Does the affected section read the root mode through CSS or receive a concrete value from component state?
  • Is its theme CSS available at first paint?
  • Does the flash end when hydration completes?

Slowing or pausing hydration is useful here. If the wrong colors remain until the application runs, you have a dependency on client initialization. If the correct mode is already present but the stylesheet arrives later, investigate CSS loading instead.

In Vortex, the root preference was applied early. The stale value was further down the tree.

Separate preference from appearance

A theme API often groups several decisions under one word: “theme.” Keeping them distinct makes the loading sequence easier to reason about.

Preference is the visitor's choice: light, dark, or follow the system. Resolved mode is the concrete light or dark value currently in use. Scope appearance describes how a section relates to an enclosing mode: inherited, inverted, or fixed.

Your application should define preference precedence explicitly. A common policy is to use a saved explicit choice first, then the operating system preference, then a default if neither is available. A product can also deliberately default to light. That is a policy choice, not something a component library should silently decide.

If a cookie makes the resolved preference available to the server, you can render with it, accounting for the implications for caching. If the preference lives only in local storage, an early inline script can resolve it before the page content paints. A CSS-only system preference may also be sufficient when there is no saved override. Choose the simplest path that matches your application's requirements.

Here is an example bootstrap for an application that defaults to the system preference. The storage key and attribute name are illustrative:

let preference;
try {
  preference = localStorage.getItem("theme-preference");
} catch {
  // Continue with the system preference if storage is unavailable.
}

const mode =
  preference === "light" || preference === "dark"
    ? preference
    : matchMedia("(prefers-color-scheme: dark)").matches
      ? "dark"
      : "light";

document.documentElement.setAttribute("data-theme-mode", mode);

Run that before themed content can paint, with the appropriate nonce or hash if your content security policy requires it. An effect that runs after the application mounts is too late to guarantee the first paint.

After hydration, initialize the application's state from the same resolution. If the visitor chose System, subscribe to preference changes while that option remains selected. Keep the saved preference separate from the resolved mode so a system change does not accidentally turn into a permanent explicit choice.

This addresses preference loading. It does not automatically fix every nested scope.

Keep relative scopes relative

The Vortex bug appeared in Sleek's 404 page. Its footer intentionally uses the opposite mode from the page:

<VortexProvider theme="sleek" defaultColorMode="light">
  <Page />
  <ThemeScope colorMode="inverted">
    <Footer />
  </ThemeScope>
</VortexProvider>

The server rendered a light page and calculated a dark footer. Sleek's existing bootstrap then restored a saved dark preference on the root. The page became dark, but the footer still held the concrete dark value calculated on the server. Only after hydration did React recalculate the footer as light.

The problem was resolving “opposite of the page” to “dark” without preserving the relationship in the HTML.

If your system supports relative scopes, emit that relationship alongside any concrete fallback value:

<html data-theme-mode="light">
  <body>
    <main class="surface">Page content</main>
    <footer
      class="surface"
      data-theme-mode="dark"
      data-theme-appearance="inverted"
    >
      Footer content
    </footer>
  </body>
</html>

Here, dark is the initial server calculation. inverted is the relationship that remains true when the root changes before hydration.

These are example attributes for a custom system. Vortex uses data-vortex-color-mode and data-vortex-appearance for the same distinction. You do not need those exact names; you need a representation the browser can use before your components initialize.

Keep fixed light and dark scopes absolute. “Always light” and “opposite of the page” are different contracts even when they happen to produce the same initial color.

Let CSS resolve paired colors

Once HTML preserves the relationship, CSS needs enough information to resolve the palette.

The light-dark() function selects one of two colors according to the element's used color scheme. You can set that scheme explicitly with color-scheme: light or color-scheme: dark. See MDN's reference and web.dev's explanation for the underlying behavior.

For a small system with one palette, the following CSS demonstrates the complete relationship. It retains concrete fallback colors and enhances relative scopes in supporting browsers:

[data-theme-mode="light"] {
  color-scheme: light;
  --surface: white;
  --foreground: #171717;
}

[data-theme-mode="dark"] {
  color-scheme: dark;
  --surface: #171717;
  --foreground: white;
}

[data-theme-mode="light"]:not([data-theme-appearance]) {
  --boundary-scheme: light;
  --opposite-scheme: dark;
}

[data-theme-mode="dark"]:not([data-theme-appearance]) {
  --boundary-scheme: dark;
  --opposite-scheme: light;
}

.surface {
  background: var(--surface);
  color: var(--foreground);
}

@supports (color: light-dark(white, black)) {
  [data-theme-appearance] {
    --surface: light-dark(white, #171717);
    --foreground: light-dark(#171717, white);
  }

  [data-theme-appearance="inherited"] {
    color-scheme: var(--boundary-scheme);
  }

  [data-theme-appearance="inverted"] {
    color-scheme: var(--opposite-scheme);
  }
}

The root establishes its normal and opposite schemes. The footer selects the opposite. When the bootstrap changes the root to dark, the inherited --opposite-scheme becomes light. The footer's paired colors then resolve to light, even while its concrete fallback attribute still says dark.

Notice that only absolute boundaries establish the two scheme variables. Relative scopes consume them. A fixed-mode section, represented by a mode attribute without an appearance attribute, establishes a new boundary for its descendants.

This example uses a single palette. It does not implement Vortex's checks for mixing generated theme versions, which become important when a system supports composed themes.

Define nesting before you generate selectors

For nested scopes, decide what an appearance attribute is relative to. In the CSS above, it is relative to the nearest absolute boundary, not simply the nearest scope element.

Your rendering layer therefore needs to normalize the relationship. Starting at an absolute boundary, track whether the current relative path is inverted:

  • Inheriting preserves the current inversion state.
  • Inverting toggles it.
  • Entering a fixed or independent boundary resets it.

Two inversions then resolve to inherited appearance relative to that boundary. Simply putting inverted on both nested elements would be incorrect with this CSS: both would select the same opposite slot.

Vortex computes this accumulated relationship through context and emits it in the server HTML. React does that structural work once during rendering; CSS can respond to a changed boundary mode before hydration.

An independently scoped provider is a separate case. Vortex exposes that through VortexProvider scoped, which gives an embedded section its own theme and mode. In another library, it might be a separate provider or an explicit boundary component. The important rule is that its descendants resolve against that local boundary, while the page can change independently.

Also test popovers, dialogs, and other portals. CSS inheritance follows DOM ancestry, whereas component context follows the component tree. A portalled surface needs the appropriate theme and boundary information at its rendered location. Correct inline content does not prove that an overlay will be correct.

Make the generator own repetitive rules

If your design system already has light and dark values for semantic color tokens, generate the paired declarations from that source. Requiring every application to maintain a second adaptive palette creates another opportunity for the two modes to drift apart.

In Vortex, we changed the theme generator rather than asking consumers to rewrite their token configuration. Applications still define their themes in vortex.config.ts; regeneration emits the additional rules.

We limited the enhancement to paired color tokens. Mode-dependent spacing, typography, and other non-color values continue to use the concrete-mode behavior. Missing color pairs are another case to handle explicitly: generating half an adaptive palette can make the scope internally inconsistent.

Composition adds a second question: do all loaded fragments support the new behavior? An application might load a newly generated base theme with an older brand override.

Vortex's generated fragments declare private capability markers. A composed scope requires all of its fragments to advertise support before selecting the adaptive scheme. Otherwise, it uses the concrete scheme. That avoids a mixed state where native controls follow one scheme while older override colors follow another.

You may not need this mechanism if your system always ships one synchronized stylesheet. If consumers can compose independently versioned themes, account for that boundary deliberately. Also preserve selector specificity and ordering so the enhanced rules do not accidentally change which brand override wins.

Choose a fallback policy deliberately

Wrapping the new declarations in @supports lets older browsers retain the attribute-based palettes. They keep their existing appearance and hydration behavior; they do not gain the same pre-hydration correction for relative scopes.

Whether that fallback is appropriate depends on your supported browsers and users. Compatibility data can inform the decision, but support in current engines alone does not describe every application's audience.

For Vortex, we retained both forms and chose to revisit the fallback later. A library serves applications with different upgrade schedules, so removing a working path needed a stronger justification than cleaner generated output.

The cost is additional CSS to transfer and parse. Feature queries do not make those bytes disappear. Measure raw and compressed output for representative themes, then measure load behavior if you need to establish the user impact.

Splitting modern and legacy stylesheets can reduce duplication for some visitors, but introduces loading and compatibility decisions for consumers. Start with a single predictable stylesheet unless measurements justify the additional machinery.

Keep state synchronization separate from user requests

CSS resolving colors early does not remove the need for a coherent state API after hydration.

If a parent controls the mode, a child switch should request an update from that parent. It should not silently create a second authoritative state. Vortex uses the familiar value-and-callback pattern:

function App() {
  const [mode, setMode] = useState<"light" | "dark">("light");

  return (
    <VortexProvider
      theme="sleek"
      colorMode={mode}
      onColorModeChange={setMode}
    >
      <Page />
    </VortexProvider>
  );
}

This shortened example shows ownership, not preference persistence. An application with a saved or system preference must initialize and update mode from that policy.

Keep hydration and incoming prop synchronization separate from user-request callbacks. Otherwise, reading an already selected mode can look like a new request and create redundant updates or feedback loops. Uncontrolled providers can still accept an initial default and own subsequent changes internally.

Verify the page before hydration can repair it

A settled screenshot is insufficient evidence for a first-paint fix. Both the broken and corrected versions may look identical after React finishes loading.

Create a small reproduction with a visible root surface and an inverted section. Keep the preference bootstrap running, but delay or pause application hydration. Then check the computed colors before releasing it.

A useful verification sequence is:

  1. Save dark mode and reload from server-rendered light defaults.
  2. Confirm the root and relative section have the intended colors before hydration.
  3. Allow hydration and confirm neither changes unexpectedly.
  4. Repeat with light mode and with a saved preference that disagrees with the operating system.
  5. Exercise nested inversions, fixed scopes, independent boundaries, and portalled content.
  6. Check your fallback path and any supported combination of older and newer theme fragments.

We built a standalone Storybook reload example for Vortex with controllable hydration. We then packed the library into a local tarball, installed it in Sleek, and regenerated the application's CSS. That tested the distributed package and consuming application's loading sequence together.

With application JavaScript held back, the inverted footer was already light on a saved dark-mode reload. It remained light after hydration. Repeating the check against the published release confirmed that the behavior survived packaging.

Keep this reproduction separate from automatic interaction tests. Storybook's play functions can toggle a mode and restore it when a story opens, producing a visible flash of their own. That is useful test activity, but a different cause from a stale first paint.

Apply the approach at the smallest useful scope

If your whole page flashes, first check preference timing and stylesheet availability. You may only need to fix the bootstrap or make the server aware of the preference.

If the root is correct but nested sections are stale, preserve their relationship in the rendered HTML and let CSS resolve paired colors from the relevant boundary. Add inversion normalization, portal handling, and version checks only where your system's features require them.

The reusable idea is to give the browser enough information to render the intended state before the application initializes. The application owns preference policy, the rendering layer describes scope relationships, and CSS resolves the palette. In Vortex, making those responsibilities explicit let the first paint match the design without changing how consumers author their themes.

Total likes

0 likes

Share