~/blog/welders-flash|
Scan this vCard to save my contacts
#frontend

Welder's flash effect

May 01, 2023 · 10 min read

Table of contents

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:

powerthesaurus.org

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:

duckduckgo.com

twitter.com has a full-screen loader, hence they just fill that loader with a simple media query:

twitter.com

web.dev - render-blocking inline script with localStorage:

web.dev

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.