Welder's flash effect
Table of contents
- Preface
- Method #1: inline CSS
- Method #2: render-blocking inline script
- Method #3: SSR and Cookies
- Method #4: Client Hints
- Method #5: TLS 1.3 ALPS
- What do others use?
- Conclusion
- Useful links
Preface
It's been a while since dark themes came to the web, and this feature quickly became essential for every single app. Even Microsoft Word provides some sort of inverted colors mode with a white foreground and dark background.
Implementation is now as easy as pie with media queries and the prefers-color-scheme
feature.
However, there's one little issue that remains unsolved at the time I'm writing this note.
The user's preferred color scheme can only be obtained on the client side, which brings a significant drawback.
This post is not a guide on how to opt into this theme switching feature, but rather a collection of thoughts about non-trivial issues. If you are searching for such a tutorial, please check the links at the end of the page.
First, let's consider client-side apps without SSR, prerendering, Application Shell, or other techniques that require a running web server behind the scenes. In this scenario, when a user first visits a website or an app, we don't know their preference, so the only thing we can do is to render either a light or dark theme, but not their preferred one.
This will cause visible flickering when a user first loads a page, and even during all subsequent loads if developers didn't handle the case properly. Chris Coyer even gave a name to this phenomenon - FART or Flash of inAccurate coloR Theme, a special case of the FOUC problem.
The "welder's flash" effect, also known as arc eye or photokeratitis, is a painful condition caused by exposure to ultraviolet (UV) radiation, typically from welding or other intense sources of light. It occurs when the eyes are exposed to high levels of UV light without proper protection, such as a welding helmet or safety goggles.
For example, here's a screenshot of a tool I worked on in the past and occasionally use to find English synonyms and antonyms:
The page was initially loaded and rendered with a light color scheme, but after 0.6 seconds, some JavaScript/CSS was executed and applied, causing a switch from light to dark theme. As someone who prefers dark themes during nighttime, I find this experience quite frustrating. :)
Method #1: inline CSS
Not necessarily inline, but this CSS must be delivered and applied as soon as possible. The idea is to write a media query and include it in the initial HTML to be shipped to the browser:
<style>
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #333333;
--color-fg: #ffffff;
}
}
</style>
Or using <link />
with media
attribute (this requires 2 more HTTP requests):
<link media="(prefers-color-scheme: dark)" href="dark.css" rel="stylesheet" />
<link media="(prefers-color-scheme: light)" href="light.css" rel="stylesheet" />
Don't forget to provide a fallback, as not every user agent may support the color-scheme media query.
Pros:
- Easy to implement
- Suitable for environments with no JS support
Cons:
- Works only client side
- User preference can't be saved, but we can fix this
- Flickering may still be present :(
Method #2: render-blocking inline script
Another approach is to use an inline script that will block rendering until theme detection is executed.
<script type="text/javascript">
let theme = localStorage.getItem('theme');
if (!['light', 'dark'].includes(theme)) {
const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
theme = isDarkMode ? 'dark' : 'light';
localStorage.setItem('theme', theme);
}
document.body.setAttribute('data-theme', theme);
</script>
Pros:
- Theme may be saved to some storage
- We can implement an "auto" mode too
Cons:
- Works only client side
- Parsing will be blocked until this script executes
- Flickering may still be present :(
Method #3: SSR and Cookies
If we have a server, things become more interesting because cookies can be used. This means there is an ability to return static HTML that already contains the required attributes/classes together with that initial CSS.
app.get('/', (req, res) => {
// @todo make a proper check here
const theme = req.cookies['theme'];
// Do something with this information
res.send(renderToString(<App theme={theme} />));
});
... or using some sort of prerendering (e.g. using PHP):
// @todo make a proper check here
$theme = $_COOKIE['theme'] ?? 'light';
$markup = <<<HTML
<!doctype html>
<html>
<head>
<link href="/static/{$theme}.css" rel="stylesheet">
</head>
<body data-theme="{$theme}">
<main id="#root"></main>
</body>
</html>
HTML;
echo $markup;
Pros:
- Theme may be saved to some storage
- We can implement an "auto" mode too
- Works server side
Cons:
- Flickering may still be present during the very first visit
Method #4: Client Hints
Client Hints are HTTP headers that a server can request from a user agent, but which are not guaranteed to be sent. The feature is often referred to as a correct way to obtain the User-Agent string or determine whether the client is a mobile device.
But there are more interesting things we could implement using Client Hints, one of which is the ability to request color-scheme preference.
<meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" />
<meta http-equiv="Critical-CH" content="Sec-CH-Prefers-Color-Scheme" />
Here we set 2 meta tags representing their HTTP headers. Accept-CH
tells the browser that we want it to send us back
additional information about the user's preferred theme. Critical-CH
is used to mark that extra header as critical
for our app to be rendered properly.
If the browser supports the Client Hints mechanism, after receiving this initial HTML, a second request containing the required HTTP headers will be sent immediately. So the whole flow will be like this:
GET / HTTP/1.1
Host: example.com
HTTP/1.1 200 OK
Content-Type: text/html
Accept-CH: Sec-CH-Prefers-Color-Scheme
Critical-CH: Sec-CH-Prefers-Color-Scheme
Vary: Sec-CH-Prefers-Color-Scheme
GET / HTTP/1.1
Host: example.com
Sec-CH-Prefers-Color-Scheme: light
HTTP/1.1 200 OK
Content-Type: text/html
Accept-CH: Sec-CH-Prefers-Color-Scheme
Critical-CH: Sec-CH-Prefers-Color-Scheme
Vary: Sec-CH-Prefers-Color-Scheme
Pros:
- Works server side
Cons:
- Significantly increases page loading time due to 2 subsequent HTTP requests
- Doesn't work in old browsers, requires a fallback
- Flickering may still be present during the very first visit
Method #5: TLS 1.3 ALPS
Well, this is the most complex and least supported feature for now. The worst thing about the Client Hints approach, described in the previous section, is that it requires 2 RTT to get the initial markup.
Fortunately, there's a promising spec that aims to eliminate this huge drawback with connection-level preferences. It's called Client Hints Reliability and is published under WICG on GitHub.
In short, it allows setting connection-level settings and requires additional frames to be sent immediately during the very first request, right after the TLS handshake finishes. Even more interestingly, TLS 1.3 includes a 0-RTT optimization that allows the browser to send initial data after the TLS ClientHello without waiting for the response from the server. However, in this case, the browser won't have received the ACCEPT_CH frame yet.
Example from the spec:
ClientHello
+ alps
ServerHello
EncryptedExtensions
+ alps=(https://example.com, Device-Memory)
...
Finished
Finished
GET / HTTP/2.0
Host: example.com
Device-Memory: 0.5
HTTP/2.0 200 OK
Vary: Device-Memory
Accept-CH: Device-Memory
Critical-CH: Device-Memory
Anyway, if you have read the post up to this point, I highly recommend taking a look at the spec yourself.
What do others use?
duckduckgo.com engineers use an inline script with a cookie fallback:
twitter.com has a full-screen loader, hence they just fill that loader with a simple media query:
web.dev - render-blocking inline script with localStorage:
Conclusion
As of the time of writing this note, there is no foolproof method to completely eliminate white screen flickering when detecting and switching themes, both client-side and server-side.
A recommended approach is to combine the first three methods: CSS for non-JavaScript environments, JavaScript for dynamic theme switching, and using cookies with SSR or an Application Shell for subsequent visits.
There is a possibility that in the future, Sec-CH-Prefers-Color-Scheme will be included in the default set of Client Hints sent by browsers without the need for declaring Critical-CH. This may simplify certain aspects or enhance reliability, but for now, it remains uncertain.
Useful links
- prefers-color-scheme: Hello darkness, my old friend
- Client Hint Reliability
- Improved dark mode default styling with the color-scheme CSS property and the corresponding meta tag
- A Complete Guide to Dark Mode on the Web
- Dark mode in 5 minutes, with inverted lightness variables
- Meta Theme Color and Trickery
- Render Blocking CSS