You saved the user's dark mode preference.
localStorage.setItem('theme', 'dark');
Then on the next visit, your app reads it.
const theme = localStorage.getItem('theme');
document.documentElement.setAttribute('data-theme', theme);
The logic looks correct.
But the page still flashes in the wrong theme for a moment.
Why?
Because your JavaScript may be applying the correct theme too late.
Dark mode flash is usually not a theme logic problem.
It is a browser loading order problem.
The common setup
A typical dark mode setup looks like this:
:root {
--color-background: #ffffff;
--color-text: #111827;
}
:root[data-theme="dark"] {
--color-background: #111827;
--color-text: #f9fafb;
}
body {
background: var(--color-background);
color: var(--color-text);
}
Then JavaScript reads the saved theme:
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
}
This works after JavaScript runs.
The problem is what happens before JavaScript runs.
What the browser does first
When the browser loads a page, it does not wait for your whole app to be ready before doing anything.
A simplified loading order looks like this:
1. HTML starts parsing
2. CSS is discovered
3. CSS is downloaded and applied
4. Browser prepares the first paint
5. JavaScript runs
6. JavaScript reads localStorage
7. data-theme is applied
If your CSS default is light mode, then the browser can paint light mode first.
Then JavaScript runs and switches to dark mode.
That short moment is the flash.
Initial paint: light
Saved preference: dark
After JavaScript: dark
The final theme is correct.
But the first paint was wrong.
Why deferred JavaScript makes it worse
Many apps load JavaScript like this:
<script defer src="/app.js"></script>
That is normally good for performance.
But for theme initialization, it can be too late.
defer means the script waits until the document has been parsed. By that time, CSS may already be ready and the browser may be close to painting the page.
Frameworks can have the same issue.
In React, Next.js, Vue, Svelte, or other app frameworks, your theme logic may run after:
- HTML is parsed
- CSS is applied
- hydration starts
- components mount
- effects run
If your theme is applied inside a component effect, it is definitely too late for the first paint.
For example:
useEffect(() => {
const theme = localStorage.getItem('theme');
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
}
}, []);
This may work logically, but it runs after the browser has already rendered something.
That is why the user sees a flash.
localStorage is not the problem
It is easy to blame localStorage.
But localStorage itself is not the main issue.
This is fine:
const theme = localStorage.getItem('theme');
The real issue is when this line runs.
If it runs after CSS has already been applied and the first paint has happened, the user may see the wrong theme.
So the goal is simple:
Read the saved theme before the browser paints the page.
The fix: run a tiny script early
Put a small inline script in <head> before your theme CSS.
<script>
(function () {
try {
var theme = localStorage.getItem('theme');
if (theme === 'light' || theme === 'dark') {
document.documentElement.setAttribute('data-theme', theme);
}
} catch (_) {}
})();
</script>
This script should be:
- inline
- small
- synchronous
- placed early in
<head> - before the stylesheet or theme CSS
Do not use:
<script defer src="/theme.js"></script>
Do not wait for:
DOMContentLoaded
Do not put the first theme application inside:
useEffect()
Those are too late for preventing the first-paint flash.
Better loading order
With the inline script, the loading order becomes:
1. HTML starts parsing
2. Inline theme script runs
3. localStorage is read
4. data-theme is applied to <html>
5. CSS is applied
6. Browser paints the page
Now the browser sees this before painting:
<html data-theme="dark">
So this CSS applies immediately:
:root[data-theme="dark"] {
--color-background: #111827;
--color-text: #f9fafb;
}
The first paint is already dark.
No flash.
Full example
Here is a complete minimal example.
<!doctype html>
<html lang="en">
<head>
<script>
(function () {
try {
var theme = localStorage.getItem('theme');
if (theme === 'light' || theme === 'dark') {
document.documentElement.setAttribute('data-theme', theme);
}
} catch (_) {}
})();
</script>
<style>
:root,
:root[data-theme="light"] {
color-scheme: light;
--color-background: #ffffff;
--color-surface: #f8fafc;
--color-text: #111827;
--color-muted: #6b7280;
--color-border: #e5e7eb;
--color-primary: #2563eb;
--color-on-primary: #ffffff;
}
:root[data-theme="dark"] {
color-scheme: dark;
--color-background: #111827;
--color-surface: #1f2937;
--color-text: #f9fafb;
--color-muted: #9ca3af;
--color-border: #374151;
--color-primary: #60a5fa;
--color-on-primary: #111827;
}
body {
margin: 0;
min-height: 100vh;
background: var(--color-background);
color: var(--color-text);
font-family: system-ui, sans-serif;
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 1rem;
}
.button {
background: var(--color-primary);
color: var(--color-on-primary);
border: 0;
border-radius: 8px;
padding: 0.625rem 1rem;
cursor: pointer;
}
.muted {
color: var(--color-muted);
}
</style>
</head>
<body>
<main class="card">
<h1>Dark mode without theme flash</h1>
<p class="muted">
The saved theme is applied before CSS paints the page.
</p>
<button class="button" id="theme-toggle" type="button">
Toggle theme
</button>
</main>
<script>
function toggleTheme() {
var html = document.documentElement;
var current = html.getAttribute('data-theme');
var next = current === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
document
.getElementById('theme-toggle')
.addEventListener('click', toggleTheme);
</script>
</body>
</html>
The important part is not the toggle script at the bottom.
The important part is the small script at the top.
<script>
(function () {
try {
var theme = localStorage.getItem('theme');
if (theme === 'light' || theme === 'dark') {
document.documentElement.setAttribute('data-theme', theme);
}
} catch (_) {}
})();
</script>
That is what prevents the flash.
What about system preference?
You can also support the user's operating system preference with prefers-color-scheme.
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--color-background: #111827;
--color-surface: #1f2937;
--color-text: #f9fafb;
--color-muted: #9ca3af;
--color-border: #374151;
--color-primary: #60a5fa;
--color-on-primary: #111827;
}
}
The selector matters:
:root:not([data-theme="light"])
This means:
Use system dark mode unless the user explicitly selected light mode.
So your behavior becomes:
| Situation | Theme |
|---|---|
| No saved theme + OS light | light |
| No saved theme + OS dark | dark |
Saved light
|
light |
Saved dark
|
dark |
This gives you both automatic system support and manual user override.
Adding a system option
If your theme switcher has three choices:
- light
- dark
- system
Then system mode should remove the saved preference.
function resetToSystemTheme() {
document.documentElement.removeAttribute('data-theme');
localStorage.removeItem('theme');
}
Now prefers-color-scheme can control the theme again.
Why color-scheme matters
You may notice this line:
color-scheme: dark;
This is not only for your custom CSS.
It also tells the browser that your page supports a dark color scheme.
That can affect browser-controlled UI such as:
- form inputs
- scrollbars
- default controls
- built-in rendering behavior
So when you switch themes, update color-scheme too.
The component CSS should not change
A good dark mode setup does not require separate component styles like this:
.button {
background: blue;
color: white;
}
.dark .button {
background: lightblue;
color: black;
}
A better approach is:
.button {
background: var(--color-primary);
color: var(--color-on-primary);
}
The component reads semantic variables.
The theme controls the values.
Light mode changes the values.
Dark mode changes the values.
The component CSS stays the same.
The bottom line
If your dark mode flashes, your theme logic may not be wrong.
It may simply be running too late.
The key rule is:
Set the saved theme before CSS is applied.
Use a tiny inline script in <head>:
<script>
(function () {
try {
var theme = localStorage.getItem('theme');
if (theme === 'light' || theme === 'dark') {
document.documentElement.setAttribute('data-theme', theme);
}
} catch (_) {}
})();
</script>
Then let CSS variables handle the rest.
Your components can keep using:
background: var(--color-background);
color: var(--color-text);
The values change.
The component contract stays the same.
Top comments (1)
I've seen this flash happen when using localStorage for theme settings, does using CSS variables to store the theme help mitigate this issue? I'd love to hear your thoughts on this.