[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-prefers-color-scheme-theme-flash":44,"search-suggestions":60,"quiz-article-prefers-color-scheme-theme-flash":107,"path-membership-prefers-color-scheme-theme-flash":300},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a1a007-b925-75ae-927b-4afa143db631","prefers-color-scheme-theme-flash","PRACTICE_QUIZ","Theme flash: prefers-color-scheme vs. script timing","Eight questions on what prefers-color-scheme actually does, why a pure-CSS dark mode never flashes, and why your theme-toggle script might be causing the flash you're blaming on CSS.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},8,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,76,80,84,88,92,96,99,103],{"slug":62,"name":63,"articles":64},"webdev","Webdev",173,{"slug":66,"name":67,"articles":68},"javascript","Javascript",140,{"slug":70,"name":71,"articles":72},"frontend","Frontend",96,{"slug":74,"name":75,"articles":53},"tutorial","Tutorial",{"slug":77,"name":78,"articles":79},"css","Css",51,{"slug":81,"name":82,"articles":83},"performance","Performance",30,{"slug":85,"name":86,"articles":87},"typescript","Typescript",25,{"slug":89,"name":90,"articles":91},"react","React",20,{"slug":93,"name":94,"articles":95},"browser","Browser",18,{"slug":97,"name":98,"articles":95},"programming","Programming",{"slug":100,"name":101,"articles":102},"node","Node",15,{"slug":104,"name":105,"articles":106},"html","Html",12,{"id":108,"slug":46,"title":109,"subtitle":52,"excerpt":110,"coverUrl":111,"locale":13,"readingMinutes":112,"publishedAt":113,"viewCount":114,"likeCount":19,"commentCount":19,"author":115,"vertical":120,"topic":121,"tags":123,"_count":128,"playground":130,"body":132,"bodyMd":286,"seo":287,"translationGroupId":290,"series":52,"podcastUrl":52,"verticalId":5,"thread":291,"assessments":293,"translations":296,"quiz":298},"01a1a007-b7cf-743d-a21f-35adebf60420","Stop Blaming `prefers-color-scheme` For Your Theme Flash","Your dark mode toggle flashes light for a frame on every reload — but prefers-color-scheme alone never does that. The real bug is where your theme script runs relative to the browser's first paint.","\u002Fmedia\u002Fcovers\u002Fprefers-color-scheme-theme-flash.png",6,"2026-11-09T15:42:44.790Z",9,{"id":116,"name":117,"username":118,"avatarUrl":52,"headline":119},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":77,"name":122},"CSS",[124,125,126,127],{"slug":77,"name":78,"color":52},{"slug":66,"name":67,"color":52},{"slug":62,"name":63,"color":52},{"slug":70,"name":71,"color":52},{"assessments":129},1,{"slug":46,"title":131},"prefers-color-scheme theme flash — interactive playground",{"blocks":133,"version":129},[134,138,141,144,148,151,157,160,163,166,169,173,176,180,183,186,189,192,195,199,202,209,212,215,218,221,224,229,232,235,238,241,244,247,250,253,256,259,262,265,268,271,274,277,280],{"id":135,"html":136,"type":137},"b1","\u003Cp>You ship a dark mode toggle. It works — click it, the page goes dark, click again, it goes back. You&#39;re happy. Then you refresh the page with dark mode saved, and for one frame the screen is a blinding white rectangle before it snaps to black.\u003C\u002Fp>","paragraph",{"id":139,"html":140,"type":137},"b2","\u003Cp>It&#39;s not every browser. It&#39;s not every time. But it&#39;s there often enough that someone on your team files it as &quot;dark mode flickers&quot; and you go looking for the bug in \u003Ccode>prefers-color-scheme\u003C\u002Fcode>.\u003C\u002Fp>",{"id":142,"html":143,"type":137},"b3","\u003Cp>It&#39;s not there. The media query is working exactly as designed. The bug is somewhere you didn&#39;t think to look: the \u003Cem>order\u003C\u002Fem> your scripts run in, relative to the one paint you don&#39;t get a second chance at.\u003C\u002Fp>",{"id":145,"html":146,"text":146,"type":147,"level":31},"b4","The fix everyone reaches for first","heading",{"id":149,"html":150,"type":137},"b5","\u003Cp>Here&#39;s the toggle, built the way most tutorials show it:\u003C\u002Fp>",{"id":152,"code":153,"type":154,"language":155,"highlight":156},"b6","\u002F\u002F theme-toggle.js — loaded normally, no special placement\nconst stored = localStorage.getItem(\"theme\");\nconst theme = stored ?? (matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\");\ndocument.documentElement.dataset.theme = theme;\n\ndocument.querySelector(\"#toggle\").addEventListener(\"click\", () => {\n  const next = document.documentElement.dataset.theme === \"dark\" ? \"light\" : \"dark\";\n  document.documentElement.dataset.theme = next;\n  localStorage.setItem(\"theme\", next);\n});","code","js",[],{"id":158,"html":159,"type":137},"b7","\u003Cp>Perfectly reasonable code. It checks \u003Ccode>localStorage\u003C\u002Fcode> for a saved choice, falls back to the OS preference, and sets a \u003Ccode>data-theme\u003C\u002Fcode> attribute your CSS keys off. Toggling works instantly because it&#39;s all synchronous DOM manipulation in response to a click.\u003C\u002Fp>",{"id":161,"html":162,"type":137},"b8","\u003Cp>So why does \u003Cem>reloading\u003C\u002Fem> with dark mode saved still flash light?\u003C\u002Fp>",{"id":164,"html":165,"type":137},"b9","\u003Cp>Because this script, like almost every script you write, loads after the browser has already parsed your HTML and applied your CSS. By the time it runs, the page has already painted once — in whatever theme your CSS defaults to, usually light, because that&#39;s what browsers assume when nothing says otherwise. Your script then flips the theme a beat later, and that flip is the flash. You didn&#39;t write a buggy script. You wrote a script that runs too late to stop a paint that&#39;s already happened.\u003C\u002Fp>",{"id":167,"html":168,"type":137},"b10","\u003Cp>Before you scroll — where do you think this script is sitting in the HTML? Top of \u003Ccode>&lt;head&gt;\u003C\u002Fcode>? Bottom of \u003Ccode>&lt;body&gt;\u003C\u002Fcode>? It matters more than anything else in this post.\u003C\u002Fp>",{"id":170,"html":171,"text":172,"type":147,"level":31},"b11","Why \u003Ccode>prefers-color-scheme\u003C\u002Fcode> alone never does this","Why prefers-color-scheme alone never does this",{"id":174,"html":175,"type":137},"b12","\u003Cp>Here&#39;s the part that trips people up: if you throw out the toggle entirely and do dark mode with pure CSS, there&#39;s no flash, ever.\u003C\u002Fp>",{"id":177,"code":178,"type":154,"language":77,"highlight":179},"b13",":root {\n  --bg: white;\n  --fg: black;\n}\n\n@media (prefers-color-scheme: dark) {\n  :root {\n    --bg: #111;\n    --fg: #eee;\n  }\n}\n\nbody { background: var(--bg); color: var(--fg); }",[],{"id":181,"html":182,"type":137},"b14","\u003Cp>No JavaScript, no flash. \u003Ccode>prefers-color-scheme\u003C\u002Fcode> is a media feature the browser evaluates while it&#39;s constructing the render tree — the same moment it resolves \u003Ccode>@media (min-width: 768px)\u003C\u002Fcode> or \u003Ccode>prefers-reduced-motion\u003C\u002Fcode>. That happens \u003Cem>before\u003C\u002Fem> the first paint, not after it, so there&#39;s nothing to flip once pixels are on screen. CSS parsing is render-blocking specifically so the browser doesn&#39;t have to paint twice.\u003C\u002Fp>",{"id":184,"html":185,"type":137},"b15","\u003Cp>Current browsers only ever resolve it to \u003Ccode>light\u003C\u002Fcode> or \u003Ccode>dark\u003C\u002Fcode> — an earlier draft of the spec defined a third value, \u003Ccode>no-preference\u003C\u002Fcode>, for &quot;the OS has no setting,&quot; but no engine shipped it as a live, matchable state, so it was dropped; when there&#39;s nothing to detect, browsers just report \u003Ccode>light\u003C\u002Fcode>. Either way, it&#39;s read-only. It tells you what the OS wants. It has no opinion about a manual toggle, localStorage, or anything your app decides on top of it. The moment you add &quot;but let the user override it,&quot; you&#39;ve introduced a second source of truth that only JavaScript can read — and JavaScript, by default, runs after paint.\u003C\u002Fp>",{"id":187,"html":188,"type":137},"b16","\u003Cp>So the honest version of the bug report isn&#39;t &quot;\u003Ccode>prefers-color-scheme\u003C\u002Fcode> flickers.&quot; It&#39;s &quot;my override script runs after the thing it&#39;s supposed to override has already rendered.&quot;\u003C\u002Fp>",{"id":190,"html":191,"text":191,"type":147,"level":31},"b17","The fix: make the override run before paint, not after",{"id":193,"html":194,"type":137},"b18","\u003Cp>The browser doesn&#39;t execute scripts in document order and \u003Cem>then\u003C\u002Fem> paint — it paints as it goes, and it blocks on anything synchronous it hits along the way. That&#39;s the lever. If you put a small, blocking script in \u003Ccode>&lt;head&gt;\u003C\u002Fcode>, before your stylesheet and before the rest of the page, it runs during that initial block — before there&#39;s anything to paint yet.\u003C\u002Fp>",{"id":196,"code":197,"type":154,"language":104,"highlight":198},"b19","\u003Chead>\n  \u003Cscript>\n    (function () {\n      const stored = localStorage.getItem(\"theme\");\n      const theme = stored ?? (matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\");\n      document.documentElement.dataset.theme = theme;\n    })();\n  \u003C\u002Fscript>\n  \u003Clink rel=\"stylesheet\" href=\"\u002Fstyles.css\" \u002F>\n\u003C\u002Fhead>",[],{"id":200,"html":201,"type":137},"b20","\u003Cp>Three things make this work, and dropping any one of them breaks it:\u003C\u002Fp>",{"id":203,"type":204,"items":205,"ordered":18},"b21","list",[206,207,208],"\u003Cstrong>It&#39;s inline\u003C\u002Fstrong>, not \u003Ccode>&lt;script src=&quot;...&quot;&gt;\u003C\u002Fcode>. An external file is one more network request the browser has to fetch and parse before it can run — more time for the default paint to sneak in.","\u003Cstrong>It has no \u003Ccode>async\u003C\u002Fcode> or \u003Ccode>defer\u003C\u002Fcode>.\u003C\u002Fstrong> Both exist specifically to let the browser \u003Cem>not\u003C\u002Fem> block on a script. You want the opposite here — on purpose, once, for eight lines.","\u003Cstrong>It&#39;s in \u003Ccode>&lt;head&gt;\u003C\u002Fcode>, above the stylesheet.\u003C\u002Fstrong> Your CSS still needs \u003Ccode>[data-theme=&quot;dark&quot;] { ... }\u003C\u002Fcode> rules to react to the attribute — the script just has to set that attribute before the CSS gets a chance to compute without it.",{"id":210,"html":211,"type":137},"b22","\u003Cp>Move the same code to the bottom of \u003Ccode>&lt;body&gt;\u003C\u002Fcode>, or into a \u003Ccode>defer\u003C\u002Fcode>red file, or behind \u003Ccode>DOMContentLoaded\u003C\u002Fcode>, and you&#39;ve recreated the exact bug you just fixed — you&#39;ve just moved the race further down the page instead of removing it.\u003C\u002Fp>",{"id":213,"html":214,"text":214,"type":147,"level":31},"b23","The one gotcha this causes in React",{"id":216,"html":217,"type":137},"b24","\u003Cp>If you&#39;re doing this in a React app — and especially in a server-rendered one like Next.js — you&#39;ll hit a second-order problem the moment this script works.\u003C\u002Fp>",{"id":219,"html":220,"type":137},"b25","\u003Cp>The server renders \u003Ccode>&lt;html&gt;\u003C\u002Fcode> with no \u003Ccode>data-theme\u003C\u002Fcode> at all; it has no access to the visitor&#39;s \u003Ccode>localStorage\u003C\u002Fcode>. Your inline script then sets that attribute directly on the real DOM node, outside of React, before React ever hydrates. React&#39;s hydration pass compares the server-rendered markup it expected against what&#39;s actually in the DOM — and now they don&#39;t match on that one attribute. You&#39;ll get a hydration warning for an attribute you set on purpose.\u003C\u002Fp>",{"id":222,"html":223,"type":137},"b26","\u003Cp>The fix isn&#39;t to stop setting it. It&#39;s to tell React this one attribute is expected to differ:\u003C\u002Fp>",{"id":225,"code":226,"type":154,"language":227,"highlight":228},"b27","\u003Chtml lang=\"en\" suppressHydrationWarning>","jsx",[],{"id":230,"html":231,"type":137},"b28","\u003Cp>\u003Ccode>suppressHydrationWarning\u003C\u002Fcode> only silences the warning for that element&#39;s own attributes and text — it doesn&#39;t disable hydration checks anywhere else in the tree, so it&#39;s safe to scope to just the \u003Ccode>&lt;html&gt;\u003C\u002Fcode> tag carrying the theme. This is the exact tradeoff theme libraries like \u003Ccode>next-themes\u003C\u002Fcode> are built around: inject the blocking script, suppress the one expected mismatch, let everything else hydrate normally.\u003C\u002Fp>",{"id":233,"html":234,"text":234,"type":147,"level":31},"b29","What you actually fixed",{"id":236,"html":237,"type":137},"b30","\u003Cp>The flash was never a CSS problem, and it was never really a \u003Ccode>prefers-color-scheme\u003C\u002Fcode> problem either — that media feature did its job correctly the entire time. The bug was a question of sequence: something ran after the browser&#39;s first paint that needed to run before it. Once the override script moves into that pre-paint window, there&#39;s no gap left for the wrong theme to be visible in.\u003C\u002Fp>",{"id":239,"html":240,"type":137},"b31","\u003Cp>Go check your own toggle. Is the theme script inline in \u003Ccode>&lt;head&gt;\u003C\u002Fcode>, or did it end up wherever your bundler happened to put it? If it&#39;s the second one, you already know what to fix tonight — what&#39;s the worst flash you&#39;ve shipped without noticing?\u003C\u002Fp>",{"id":242,"html":243,"type":137},"b32","\u003C!-- playground:start -->",{"id":245,"html":246,"text":246,"type":147,"level":31},"b33","🎮 Try it yourself",{"id":248,"html":249,"type":137},"b34","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fprefers-color-scheme-theme-flash\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":251,"html":252,"type":137},"b35","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":254,"html":255,"type":137},"b36","\u003C!-- playground:end -->",{"id":257,"html":258,"type":137},"b37","\u003C!-- quiz:start -->",{"id":260,"html":261,"text":261,"type":147,"level":31},"b38","🧠 Test yourself",{"id":263,"html":264,"type":137},"b39","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fprefers-color-scheme-theme-flash\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":266,"html":267,"type":137},"b40","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":269,"html":270,"type":137},"b41","\u003C!-- quiz:end -->",{"id":272,"type":273},"b42","divider",{"id":275,"html":276,"type":137},"b43","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":278,"html":279,"type":137},"b44","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":281,"type":204,"items":282,"ordered":18},"b45",[283,284,285],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","You ship a dark mode toggle. It works — click it, the page goes dark, click again, it goes back. You're happy. Then you refresh the page with dark mode saved, and for one frame the screen is a blinding white rectangle before it snaps to black.\n\nIt's not every browser. It's not every time. But it's there often enough that someone on your team files it as \"dark mode flickers\" and you go looking for the bug in `prefers-color-scheme`.\n\nIt's not there. The media query is working exactly as designed. The bug is somewhere you didn't think to look: the *order* your scripts run in, relative to the one paint you don't get a second chance at.\n\n## The fix everyone reaches for first\n\nHere's the toggle, built the way most tutorials show it:\n\n```js\n\u002F\u002F theme-toggle.js — loaded normally, no special placement\nconst stored = localStorage.getItem(\"theme\");\nconst theme = stored ?? (matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\");\ndocument.documentElement.dataset.theme = theme;\n\ndocument.querySelector(\"#toggle\").addEventListener(\"click\", () => {\n  const next = document.documentElement.dataset.theme === \"dark\" ? \"light\" : \"dark\";\n  document.documentElement.dataset.theme = next;\n  localStorage.setItem(\"theme\", next);\n});\n```\n\nPerfectly reasonable code. It checks `localStorage` for a saved choice, falls back to the OS preference, and sets a `data-theme` attribute your CSS keys off. Toggling works instantly because it's all synchronous DOM manipulation in response to a click.\n\nSo why does *reloading* with dark mode saved still flash light?\n\nBecause this script, like almost every script you write, loads after the browser has already parsed your HTML and applied your CSS. By the time it runs, the page has already painted once — in whatever theme your CSS defaults to, usually light, because that's what browsers assume when nothing says otherwise. Your script then flips the theme a beat later, and that flip is the flash. You didn't write a buggy script. You wrote a script that runs too late to stop a paint that's already happened.\n\nBefore you scroll — where do you think this script is sitting in the HTML? Top of `\u003Chead>`? Bottom of `\u003Cbody>`? It matters more than anything else in this post.\n\n## Why `prefers-color-scheme` alone never does this\n\nHere's the part that trips people up: if you throw out the toggle entirely and do dark mode with pure CSS, there's no flash, ever.\n\n```css\n:root {\n  --bg: white;\n  --fg: black;\n}\n\n@media (prefers-color-scheme: dark) {\n  :root {\n    --bg: #111;\n    --fg: #eee;\n  }\n}\n\nbody { background: var(--bg); color: var(--fg); }\n```\n\nNo JavaScript, no flash. `prefers-color-scheme` is a media feature the browser evaluates while it's constructing the render tree — the same moment it resolves `@media (min-width: 768px)` or `prefers-reduced-motion`. That happens *before* the first paint, not after it, so there's nothing to flip once pixels are on screen. CSS parsing is render-blocking specifically so the browser doesn't have to paint twice.\n\nCurrent browsers only ever resolve it to `light` or `dark` — an earlier draft of the spec defined a third value, `no-preference`, for \"the OS has no setting,\" but no engine shipped it as a live, matchable state, so it was dropped; when there's nothing to detect, browsers just report `light`. Either way, it's read-only. It tells you what the OS wants. It has no opinion about a manual toggle, localStorage, or anything your app decides on top of it. The moment you add \"but let the user override it,\" you've introduced a second source of truth that only JavaScript can read — and JavaScript, by default, runs after paint.\n\nSo the honest version of the bug report isn't \"`prefers-color-scheme` flickers.\" It's \"my override script runs after the thing it's supposed to override has already rendered.\"\n\n## The fix: make the override run before paint, not after\n\nThe browser doesn't execute scripts in document order and *then* paint — it paints as it goes, and it blocks on anything synchronous it hits along the way. That's the lever. If you put a small, blocking script in `\u003Chead>`, before your stylesheet and before the rest of the page, it runs during that initial block — before there's anything to paint yet.\n\n```html\n\u003Chead>\n  \u003Cscript>\n    (function () {\n      const stored = localStorage.getItem(\"theme\");\n      const theme = stored ?? (matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\");\n      document.documentElement.dataset.theme = theme;\n    })();\n  \u003C\u002Fscript>\n  \u003Clink rel=\"stylesheet\" href=\"\u002Fstyles.css\" \u002F>\n\u003C\u002Fhead>\n```\n\nThree things make this work, and dropping any one of them breaks it:\n\n- **It's inline**, not `\u003Cscript src=\"...\">`. An external file is one more network request the browser has to fetch and parse before it can run — more time for the default paint to sneak in.\n- **It has no `async` or `defer`.** Both exist specifically to let the browser *not* block on a script. You want the opposite here — on purpose, once, for eight lines.\n- **It's in `\u003Chead>`, above the stylesheet.** Your CSS still needs `[data-theme=\"dark\"] { ... }` rules to react to the attribute — the script just has to set that attribute before the CSS gets a chance to compute without it.\n\nMove the same code to the bottom of `\u003Cbody>`, or into a `defer`red file, or behind `DOMContentLoaded`, and you've recreated the exact bug you just fixed — you've just moved the race further down the page instead of removing it.\n\n## The one gotcha this causes in React\n\nIf you're doing this in a React app — and especially in a server-rendered one like Next.js — you'll hit a second-order problem the moment this script works.\n\nThe server renders `\u003Chtml>` with no `data-theme` at all; it has no access to the visitor's `localStorage`. Your inline script then sets that attribute directly on the real DOM node, outside of React, before React ever hydrates. React's hydration pass compares the server-rendered markup it expected against what's actually in the DOM — and now they don't match on that one attribute. You'll get a hydration warning for an attribute you set on purpose.\n\nThe fix isn't to stop setting it. It's to tell React this one attribute is expected to differ:\n\n```jsx\n\u003Chtml lang=\"en\" suppressHydrationWarning>\n```\n\n`suppressHydrationWarning` only silences the warning for that element's own attributes and text — it doesn't disable hydration checks anywhere else in the tree, so it's safe to scope to just the `\u003Chtml>` tag carrying the theme. This is the exact tradeoff theme libraries like `next-themes` are built around: inject the blocking script, suppress the one expected mismatch, let everything else hydrate normally.\n\n## What you actually fixed\n\nThe flash was never a CSS problem, and it was never really a `prefers-color-scheme` problem either — that media feature did its job correctly the entire time. The bug was a question of sequence: something ran after the browser's first paint that needed to run before it. Once the override script moves into that pre-paint window, there's no gap left for the wrong theme to be visible in.\n\nGo check your own toggle. Is the theme script inline in `\u003Chead>`, or did it end up wherever your bundler happened to put it? If it's the second one, you already know what to fix tonight — what's the worst flash you've shipped without noticing?\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fprefers-color-scheme-theme-flash\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fprefers-color-scheme-theme-flash\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":109,"canonical":288,"description":289},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fprefers-color-scheme-theme-flash","Your dark mode toggle flashes light for a frame on every reload — but prefers-color-scheme alone never does that. The real bug is where your theme script runs relative to the brows","01a1a007-b7d0-74db-beb5-c7bb845f996a",{"id":292,"locked":18},"01a1a007-b902-750b-8b32-fe428d0699c5",[294],{"id":45,"slug":46,"title":48,"_count":295},{"questions":51},[297],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":299,"questionCount":51},{"questions":51},[]]