AI links open with a title + excerpt (these tools can't fetch the page themselves) — use "Copy full article" to paste the complete text for a fuller summary.
Introduction
This article demonstrates how to build a dark mode toggle in Angular that does three things correctly: follow the operating system by default, remember a manual choice, and never flash the wrong theme while loading.
That third one is the hard part, and it cannot be solved inside Angular at all.
The Flash
You have seen it. A site loads white for a moment, then turns dark. On a prerendered site it is worse, because the HTML arrives fast and your app starts a moment later.
The cause is timing. The HTML and CSS render immediately. Your Angular code runs after that. So for those few frames, the page uses whatever the stylesheet says by default — and if that is light while the user wants dark, they see a white flash.
No amount of Angular code fixes this, because all of it runs too late. APP_INITIALIZER is too late. A service constructor is too late. The theme must be applied before the browser paints anything.
Features of this approach
- Correct theme on the very first frame.
- Follows the OS while the user has not chosen.
- Stops following once they choose, and remembers it.
- One class on
<html>, so all styling is plain CSS.
With the following steps, you can build it.
- Apply the theme before paint.
- Hold the state in a signal.
- Follow the OS, but only until the user decides.
- Set the browser chrome colour.
Apply the Theme Before Paint
A small blocking script in the <head> of index.html, before any stylesheet or app code.
<!-- Apply the right theme before first paint, to avoid a light/dark flash -->
<script>
(function () {
var stored = localStorage.getItem('theme');
var dark = stored ? stored === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', dark);
})();
</script>
Five lines, and every detail is deliberate.
It is not async or defer. It must block. That is the entire point — the browser stops, runs this, adds the class, and only then paints.
It reads localStorage directly. No framework, no imports. Nothing else has loaded yet.
The fallback is prefers-color-scheme. A first-time visitor gets the theme their operating system already says they want, with no flash and nothing to click.
It uses var and a plain function. This script runs before any bundling or transpiling, straight from the HTML. Keep it boring and it works in everything.
It puts the class on documentElement, which is <html>, not <body>. That matters for Tailwind's darkMode: 'class', and it means the background colour is right before <body> exists.
Yes, this duplicates logic that also lives in your Angular service. That duplication is the solution, not a smell. One copy runs at the only moment that can prevent the flash; the other owns the state for the rest of the session.
Hold the State in a Signal
@Injectable({ providedIn: 'root' })
export class ThemeService {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private media = this.isBrowser ? window.matchMedia('(prefers-color-scheme: dark)') : null;
theme = signal<Theme>(this.resolveInitialTheme());
constructor() {
this.apply(this.theme());
// …
}
toggle(): void {
this.set(this.theme() === 'dark' ? 'light' : 'dark', true);
}
private apply(theme: Theme): void {
if (!this.isBrowser) return;
document.documentElement.classList.toggle('dark', theme === 'dark');
}
}
The isBrowser guard appears three times, and it is not defensive padding. Our app is prerendered at build time, where there is no window, no localStorage and no matchMedia. Without these guards the build crashes.
resolveInitialTheme() returns 'light' on the server for the same reason — it has nothing to read. The browser corrects it before paint with the inline script, so the server's guess is never visible.
The state is a signal, so a template can bind theme() directly and the toggle icon updates with no subscription to manage.
Follow the OS, But Only Until the User Decides
This is the behaviour people get wrong, usually by making the toggle a simple override that ignores the OS forever.
this.media?.addEventListener('change', (e) => {
if (localStorage.getItem(STORAGE_KEY)) return;
this.set(e.matches ? 'dark' : 'light', false);
});
Read the rule carefully. If a stored preference exists, do nothing. Otherwise, follow the OS live.
So a visitor who never touches your toggle gets their theme switched automatically when their laptop moves to night mode, while the tab is open. A visitor who clicked your toggle has stated a preference, and their OS does not get to override it later.
The persist flag carries the same idea into set().
private set(theme: Theme, persist: boolean): void {
this.theme.set(theme);
this.apply(theme);
if (persist && this.isBrowser) localStorage.setItem(STORAGE_KEY, theme);
}
toggle() passes true — a click is a choice, so write it down. The OS listener passes false — following the system is not a choice, so writing it would silently lock the user out of future OS changes.
That one boolean is the difference between a toggle that feels right and one that feels stuck.
Set the Browser Chrome Colour
The last bit of polish is the browser's own UI — the address bar on mobile.
<meta name="theme-color" content="#f4f6fc" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#060d1e" media="(prefers-color-scheme: dark)">
Two tags with a media attribute, so the browser picks. Without this your dark page sits under a white address bar.
One honest gap. These follow the OS setting, not your in-app toggle. A visitor whose laptop is light but who chose dark on your site gets a dark page with a light address bar. Fixing it properly means updating the tag from JavaScript when the toggle fires. We have not done that yet.
A Note on Reversing Decisions
We removed dark mode entirely at one point, to render only the light brand. The next commit put it back.
I am including that because commit histories usually hide it, and they should not. The implementation was fine both times. What we were deciding was a product question — is one polished theme better than two good ones — and the honest answer was that we had to see it to know.
If you are doing this work, expect a round trip like that. It is not wasted; it is how the decision gets made.
Conclusion
In this article we learned why dark mode flashes and why no Angular code can prevent it, how a five-line blocking script in index.html fixes it, how to guard a theme service for prerendering, and how a single persist flag makes the difference between following the OS and respecting a user's choice.
Reference
Comments
Be the first to comment.