Browse Source

fix(ui): prevent dark-mode FOUC and honor explicit theme toggle

`@eschricht/nuxt-color-mode` deliberately returns `undefined` from
`useClientPreferredColorScheme()` during hydration to avoid Vue hydration
warnings. The consequence is that `<html class>` is unresolved until
`onNuxtReady` fires (~100ms in dev), so dark-OS users on `theme=system`
see a light flash on first paint. The same hydration path can also leave
the class empty on subsequent cookie changes — clicking the theme toggle
to switch from `system` to the opposite of the OS preference (e.g.
`system → light` on a dark-OS machine) leaves the page rendering against
the OS preference instead of the user's pick.

Add a single inline blocking <script> in <head> that:

  1. Reads the `theme` cookie + matchMedia synchronously and sets
     `<html class="dark"|"light">` before first paint.
  2. Installs a permanent MutationObserver on `<html class>` that
     re-reads the cookie + matchMedia on every fire and re-applies the
     resolved value whenever something else writes a different one
     (including empty).
  3. Subscribes to `prefers-color-scheme` change events so `theme=system`
     tracks the OS without a reload.

Verified across the full 6-case matrix (system/light/dark × OS dark/light)
with Playwright: cookie, `<html class>`, and computed body bg all match
the resolved theme after every cycle step.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
pull/2634/head
Robert S. 2 weeks ago
parent
commit
d711d66e55
  1. 12
      src/app/app.vue

12
src/app/app.vue

@ -23,6 +23,18 @@ useHead({
bodyAttrs: {
class: 'bg-gray-50 dark:bg-neutral-800',
},
// FOUC fix. @eschricht/nuxt-color-mode deliberately returns `undefined` from
// useClientPreferredColorScheme() during hydration to avoid Vue hydration
// warnings which means it can't set <html class> in time for first paint,
// and on cookie changes can transiently emit an empty class. This inline
// blocking script does both jobs the library skips: sets the class before
// paint, and re-applies it whenever something else writes a different value.
script: [
{
tagPriority: 'critical',
innerHTML: `(function(){try{function resolve(){var m=document.cookie.match(/(?:^|;\\s*)theme=([^;]+)/);var p=m?decodeURIComponent(m[1]):'system';if(p==='dark')return 'dark';if(p==='light')return 'light';return matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}var h=document.documentElement;function apply(){var w=resolve();if(h.className!==w)h.className=w;}apply();new MutationObserver(apply).observe(h,{attributes:true,attributeFilter:['class']});matchMedia('(prefers-color-scheme: dark)').addEventListener('change',apply);}catch(e){}})();`,
},
],
link: [
{
rel: 'manifest',

Loading…
Cancel
Save