Two cooperating pieces. An inline <script> in the HTML shell reads the stored preference and adds .dark to <html> synchronously before any CSS or JS module loads. A .theme-switching class disables all transitions for exactly two animation frames when toggled at runtime.
No flash on load
<!-- Must be inline, not type="module" — modules are deferred -->
<script>
try {
var p = JSON.parse(localStorage.getItem("user-preferences") ?? "{}");
if (p.theme === "dark" || (!p.theme && matchMedia("(prefers-color-scheme: dark)").matches))
document.documentElement.classList.add("dark");
} catch {}
</script>
type="module" scripts are deferred and run after first paint. A classic inline script runs as the parser hits it — .dark is on <html> before the browser lays out a single pixel.
No transition flash on switch
html.theme-switching,
html.theme-switching *,
html.theme-switching *::before,
html.theme-switching *::after {
transition-duration: 0s !important;
}
function applyTheme(dark: boolean) {
document.documentElement.classList.add("theme-switching");
document.documentElement.classList.toggle("dark", dark);
// Frame 1: class applied, transitions suppressed, browser paints instantly.
// Frame 2: transitions re-enabled, hover/focus effects resume.
requestAnimationFrame(() =>
requestAnimationFrame(() =>
document.documentElement.classList.remove("theme-switching")
)
);
}
Why two rAFs and not one: a single rAF fires before the browser paints the frame in which .theme-switching was added — transitions haven’t been suppressed for that paint yet. The second rAF ensures suppression is in effect for the actual repaint. Collapsing to one breaks it in Chrome.
Why .theme-switching and not .dark: .dark is permanent for the entire dark-mode session. Suppressing transitions on .dark would kill hover and focus effects globally. .theme-switching is ephemeral — two frames, gone.
OS theme change
matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
if (!storedThemeOverride()) applyTheme(/* system dark */);
});
Only fires when the user’s preference is “system”. Goes through the same applyTheme path so the two-rAF suppression applies.