/

Dark Mode Toggles That Do Not Flash on Load

The theme has to be right in the first painted frame. The blocking script that fixes it, the third state everyone forgets, and the transition that is worth the risk.

Serafim Korablev
Serafim Korablev
@korablev

The dark mode toggle is a two-state button with one hard problem behind it: the theme has to be right in the very first painted frame. Get that wrong and every reader with dark mode on sees a white flash on every page load, which is the most reported and least fixed bug in this whole category.

The flash, and why it happens

Your theme lives in localStorage. Your React app reads localStorage after it hydrates. Between the HTML arriving and hydration finishing, the browser paints - with whatever the stylesheet says by default, which is usually light.

The fix is a small script in the document head that runs before the first paint, reads the stored preference, and puts the class on the root element:

html

Blocking, inline, in the head, wrapped in a try because storage can throw in some privacy modes. This is what a theme library does for you, and it is why writing your own toggle without one usually reintroduces the flash.

Three states, not two

The mistake in most implementations: light and dark, no system. A reader whose operating system switches at sunset expects the site to follow, and a two-state toggle forces them to choose one forever.

Store "light", "dark" or "system". When the value is system, follow prefers-color-scheme and keep listening to it, because the OS can change while the tab is open. The toggle itself can still be a single button that cycles, as long as the current state is announced.

The toggles

Theme Toggle by Ayushmaan Singh is the most-taken set of animated variants. Animated Theme Toggler by Arunachalam does the smooth icon transition, animated-theme-toggle by axai-kaizoku is another take, and Theme toggle buttons by Le Thanh is the three-state segmented version, which is the honest shape for light, dark and system.

Curtain Theme Toggle by fatih-developer and Theme Toggle by campsite are the more elaborate ones.

Making it accessible

A theme toggle is a button, and it needs to say what it does and what state it is in. An icon that swaps between a sun and a moon says neither.

Give it a text accessible name that changes with the state: "Switch to dark theme". Or, if it is a two-state switch rather than an action, use role="switch" with aria-checked. What does not work is a bare icon with aria-label="Theme", which announces a noun and no state.

One more, easy to miss: the fancy transitions in this category are motion. Under prefers-reduced-motion: reduce, switch instantly.

The transition, which is the fun part and the risky one

Animating a theme change is tempting and it is where the frame budget goes, because transition: all on * repaints the entire page. Two approaches that work.

Transition only background-color and color on a small set of elements, for a couple of hundred milliseconds. Or use the View Transitions API, which is what makes the circular wipe effect possible: the browser snapshots the old and new states and cross-fades them, with a clip-path animation for the wipe. It degrades to an instant switch where it is unsupported, which is the correct fallback.

The tokens underneath

A toggle only works if the theme itself is built on variables. The theming guide has the full set, and the three things that break specifically when switching: borders that vanish because a dark grey on near-black has no contrast, muted foreground text that fails contrast in one mode only, and any component that hardcoded a colour instead of reading a token.

Test both modes on the same screen before shipping. The generated half of a theme is where the failures live.

Where else to look

The honest list, because the answer is not always us:

SourceBest forTrade-off
next-themesThe flash, the system state and the storage, solvedYou still build the button
prefers-color-scheme aloneFollowing the OS with no toggle and no storage at allThe reader cannot override it
View TransitionsThe circular wipe and other switch animationsUneven support; needs the instant fallback
21stThe buttons: animated, segmented and elaborateQuality varies by author, so preview before you take it

Taking one

Every component page has a live preview and the code. Installing goes through the shadcn CLI against our registry:

bash

That key comes from your 21st account, and installs require a membership. Set API_KEY_21ST once in your shell and the command works for anything in the catalogue. The 21st MCP covers the same ground without leaving the editor: ask Claude, Cursor or Codex for a theme toggle, get the previews inline, and let the agent write the file.

Browse community themes →

Frequently asked

Why does my site flash white before dark mode applies?
Because the stored preference is read after hydration, and the browser paints before that. The fix is a small blocking script in the document head that reads localStorage and adds the class to the root element before the first paint, wrapped in a try because storage can throw in some privacy modes.
Should a theme toggle have two states or three?
Three: light, dark and system. A reader whose operating system switches at sunset expects the site to follow, and a two-state toggle forces a permanent choice. When the value is system, keep listening to prefers-color-scheme, because the OS can change while the tab is open.
How do you make a theme toggle accessible?
Give it a text accessible name that changes with the state, such as switch to dark theme, or use role="switch" with aria-checked if it is a two-state control. A bare icon with aria-label="Theme" announces a noun and no state, which tells a screen reader user nothing about what pressing it will do.
Can you animate a theme change?
Yes, carefully. A transition on all properties across every element repaints the whole page, so limit it to background-color and colour on a small set. The View Transitions API is what makes the circular wipe possible, and it degrades to an instant switch where unsupported, which is the right fallback. Switch instantly under reduced motion.

Published

Aug 20, 2026

Read time

4 min

Tags

GuideThemingReactCSS

Share