DEV Community

Cover image for Detecting dark mode on every request 🌓
Bryce Dorn
Bryce Dorn

Posted on • Updated on

Detecting dark mode on every request 🌓

I'm a huge proponent of dark color schemes! Aside from causing less eye strain due to emitting less blue light, dark pixels drain less battery life (for devices with OLED screens) and make nighttime usage much more pleasant. I immediately look for the option when opening a new app and love when it's implemented well.

Recently I came across an experimental approach to help simplify dark mode adoption on the web and wanted to share with others as it resolves a related frontend issue.

That annoying flicker 💡

If you've ever implemented dark-mode in a frontend application, chances are you've encountered the "flickering" issue when loading the page. This is because the client doesn't know whether to render the light or dark version of the UI initially, causing it to flicker from light to dark if the user prefers that color scheme.

With SSR it's possible to get around this by storing the user's theme preference in document.cookie; enabling the server to check this preference on a request before responding with HTML. But even with this approach, the cookie can only be set after the user first loads a page (detecting via prefers-color-scheme and setting this cookie), still causing the page to flicker if they prefer things to be dark.

This can also become tedious if your application spans multiple subdomains, as the cookie may need to be set for each one.

Detecting on every request 🪄

Luckily there's a solution here! (For Chrome at least, more on that later.)

As of Chromium 93, the Sec-CH-Prefers-Color-Scheme client hint header enables attaching this preference to HTTP requests. By specifying that your server allows this header:

response.headers["Accept-CH"] = "Sec-CH-Prefers-Color-Scheme";
response.headers["Vary"] = "Sec-CH-Prefers-Color-Scheme";
response.headers["Critical-CH"] = "Sec-CH-Prefers-Color-Scheme";
Enter fullscreen mode Exit fullscreen mode

You can then get the user's preference from the request:

const theme = request.headers["Sec-CH-Prefers-Color-Scheme"];
// "dark" | "light"
Enter fullscreen mode Exit fullscreen mode

And send the appropriate markup to the client! Possibly via a class on the body tag:

return (
  <body class={theme}>
    ...
  </body>
);
Enter fullscreen mode Exit fullscreen mode

Or an inlined CSS variable:

return (
  <body style={{ `--color-scheme: ${theme}` }}>
    ...
  </body>
);
Enter fullscreen mode Exit fullscreen mode

Or it can integrate with the style library you're using assuming it generates styles server-side.

This is great though! No need to track color-scheme preference on the client or in your database; it can be set once on the user's device and included without any interference.

Some limitations 💭

Unfortunately this is currently only supported on recent (93+) versions of Chromium-based browsers. As we know Chrome dominates browser share, so this translates to roughly 70% of the web.

WebKit and Mozilla have requests pending to implement. Until support reaches 100% it can't (and shouldn't) be used in production, but I'm hopeful that since this delivers real value to the user experience it will get traction soon.

More information about other client hints can be found here, as well as links to examples. If this interests you spread the word and hopefully we can see this implemented across all browsers soon!

Oldest comments (17)

Collapse
 
bluefalconhd profile image
Hayes Dombroski

Safari uses webkit. Safari will get this header really close to the time it is released in webkit.

Collapse
 
darkwiiplayer profile image
Info Comment hidden by post author - thread only accessible via permalink
𒎏Wii 🏳️‍⚧️

Wouldn't it be easiest (and most widely supported) to just insert a bit of blocking javascript at the start of the page that optionally adds a class to the <body> element?

Collapse
 
bryce profile image
Bryce Dorn

That would still cause a flicker as described above. The header would enable the server to generate the appropriate static content so there's no client-side blocking.

Collapse
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️

That would still cause a flicker as described above

How so?

Thread Thread
 
bryce profile image
Bryce Dorn

For the blocking javascript to run (via <script> tag) the browser needs to load the HTML for the page. This HTML won't have a theme initially applied to it, so the page could switch from light to dark after this script runs.

Thread Thread
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️

This HTML won't have a theme initially applied to it

Yea but that's the point of the javascript block; apply the theme as the page is loading, how would that still cause any flickering?

Thread Thread
 
bryce profile image
Bryce Dorn

as the page is loading

The JS doesn't run until the page is loaded. Here's an example.

Thread Thread
 
darkwiiplayer profile image
Comment marked as low quality/non-constructive by the community. View Code of Conduct
𒎏Wii 🏳️‍⚧️

The JS doesn't run until the page is loaded

...are you sure you don't want to double-check that before making that statement in public? I really recommend doing that.

Thread Thread
 
bryce profile image
Bryce Dorn

No need to be snarky. Feel free to share a codesandbox demonstrating a blocking client-side script that doesn't flicker.

Thread Thread
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️

I am not familiar with how codesandbox does its loading, so I'm not going to go tinkering with that. For a simple index.html that you can just throw on a localhost and try it out:

<style>body.dark{background:black}</style>
<body>
   <script>
      document.body.classList.add("dark")
   </script>
</body>
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
bryce profile image
Bryce Dorn

Yes, that contrived example will load quickly enough to not have a noticeable flicker. But inlining logic directly inside a script tag isn't a good practice, nor is it always possible. If you pull that out into a file and load it via src you will notice a flicker, which is what the codesandbox example I shared demonstrates.

Thread Thread
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️

So your point is that an inline script won't work because it won't work anymore if you don't inline it? Do I need to explain how that doesn't make any sense?

As for inlining a script being "bad practice", I couldn't care less. People have been using tools to inline critical CSS for ages (or even doing it manually) so the same could be done for JS. Inline critical JS snippets that need to be there instantly, then put everything less important in its own file.

And again, I don't know how exactly codesandbox loads its content, so I can't really comment on any results you're getting with it. But if the results don't align with using a small localhost HTTP server, I'll trust the actual server setup before the sandbox.

Thread Thread
 
mangor1no profile image
Mangor1no

Developers nowadays are running wild while talking about "best pratice" and "anti-pattern" makes me reconsider my basic HTML skill everyday. To summarize, there is no such thing that exists. Everything comes with their own advantages/disadvantages, why do we bother to make things become more complicated, just add a simple tag at the begin of the body and it will be loaded before the whole thing. How slow it could be to load this tiny script? Maybe 0.0001 second or even less?

document.documentElement.classList.add('dark');

Thread Thread
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️

Yep. If you want to be really fancy, you can even use that script to set up some simple loading spinner so the page loads more nicely. All in all you'd end up with maybe 10 lines or so.

Thread Thread
 
jon_dewitt_ts profile image
Code_E_Pendant • Edited

@darkwiiplayer This tone of bullying and gatekeeping is neither helpful to the author nor those reading this conversation. I know this is like a year later, but I'd really encourage some self reflection about how inappropriate and unprofessional this was. We should strive to be a welcoming and helpful community. If we have knowledge someone else lacks, we should present it tactfully so it's apparent our goal is to teach. If your approach involves mocking your students for being stupid, then you quite frankly should not participate.

Coming back to the conversation at hand, I'd like to teach the next folks who landed here about what I learned (or relearned, really, I've been away from traditional JS for too long.)

The spec tells us that inline <script> elements are render blocking, and are executed to completion before resuming the HTML parser.

If you're like me, you may have forgotten this after becoming accustomed to modules and modern frameworks. My mental model shifted to assuming all client-side scripts were executed after rendering, which is the typical case nowadays. But this rings a bell when I recall the old days of listening for the DOMContentLoaded event or jQuery's $.ready()

Script tag comparison diagram

So if you ended up here from Google, here's the takeaway - the request headers are still exciting, but they may not be necessary to avoid that flicker or FOUC. Remember, render-blocking is not evil, it's just a performance factor to be aware of.

But, to @bryce's point, it may not be possible if you're bound by your framework. For example, I'm using Next.js and the earliest their <Script> component allows the execution is their strategy="beforeInteractive" attribute, but even though it executes before Next scripts and hydration, it's still deferred until after the first paint. I'm still working on a workaround, but I'm considering ye olde open-source contribution to request a "beforeParser" strategy.

In my case, I want to load the proper favicon based on the user's dark mode preference, but it's worth noting that if you're just trying to apply the proper styles, you can use some variation of the following CSS:

:root {
  --color-primary: #fff;
  --color-primary-contrast: #000;
}
@media (prefers-color-scheme: dark) {
  :root {
    --color-primary: #000;
    --color-primary-contrast: #fff;
  }
}
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️ • Edited

If you think calling someone a gatekeeping bully for getting a bit too snarky several months ago is gonna make them more receptive to your criticism or do anything to improve the climate of a platform, then maybe you have just as much reason to reflect.


Regarding the actual topic of the discussion,

The spec tells us that inline <script> elements are render blocking, and are executed to completion before resuming the HTML parser.

This part here is really important. It may not be as relevant in a world of frameworks, but inlining JavaScript into the HTML makes it execute as the document is parsed, blocking this process. This is generally not what developers want, but for small snippets of "critical" logic, this property of inline JS can be very useful, as in the example of changing a theme before the whole page has loaded. With some luck, the script might even apply the necessary changes before the actual stylesheet is even loaded (unless that is also inlined)

What's important to note here is that an inlined script cannot access DOM nodes that haven't been reached yet:

<body>
   <p>Before JS</p>
   <script>
      console.log(document.body.querySelectorAll("p").length)
   </script>
   <!-- prints 1 -->
   <p>After JS</p>
</body>
Enter fullscreen mode Exit fullscreen mode
Thread Thread
 
jon_dewitt_ts profile image
Code_E_Pendant

maybe you have just as much reason to reflect.

Perhaps, hard to say, but I felt compelled to reassure future readers that they don't have to feel stupid for missing this. I'm not calling you names, I'm calling you out. I've been called out before too. We're both better than that.

Credit where credit is due, you reminded me of this behavior I had long since forgotten about after more than 6 years without using DOMContentLoaded.

Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more