Skip to main content

Using client hints to prevent FARTs with website auto dark mode themes

Flash of inAccurate coloR Themes1, or FART, as coined by Chris Coyier is the fancy acronym visit a website and it quickly flashes from one color theme to another. This is most prominent for those that prefer dark mode and see pages render in light mode for a split second, then flipping over to the dark variant. It’s minor, yes, but also super annoying to anyone with a slightly slower connection, as the time at which the theme will switch to dark is dependent on the speed that the JavaScript can download and execute.

I came across a post by Jason Lengstorf about avoiding the FART by using Netlify’s new Edge Functions. Lucky me, I’m using Netlify and a statically-built site, so I figured this would be a great approach for me.

Except there was a big issue.

Many theme pickers, including the example I was following, allow picking one theme and then sticking to it. While it’s kind to provide a choice, most theme pickers miss out on providing an option for their own system to choose for them. This is handled by the CSS media query prefers-color-scheme.

Tailwindcss, which I’m using, allows a one-or-the-other approach as well. By default it uses the prefers-color-scheme approach. Alternatively, you can change it to darkMode: 'class' to control it yourself.

I originally wanted to avoid using any sort of server-side code for my site, but getting around the FART is not possible without some level of server-side processing.

In this post, I’ll walk through how I got rid of the flash of inaccurate color themes for Tailwindcss dark mode, building upon Jason’s work to add “auto” theme support using HTTP client hints and media queries.

Theme switcher component

I’m going to skip over most of my theme switch toggle button code, as mine is in Solid-js and may look a little funky if you’re used to React.

However, here are the important bits of the theme switcher, translated into React:

First, in order to manually control the light and dark mode themes with Tailwindcss, we need toset darkMode: 'class' in our configuration:

tailwind.conf.cjs
module.exports = {
  content: ['src/**/*.{astro,md,mdx,tsx,ts}'],
  darkMode: 'class',
};

To save our theme and whether or not auto-selection is preferred, we can use a basic fetch call with URLSearchParams. Nothing too special is necessary here:

function saveTheme(theme: Theme, auto: boolean) {
  const params = new URLSearchParams({ theme, auto: auto ? 'true' : 'false' });
  fetch(`/api/theme/?${params.toString()}`);
}

Then, in our <ThemeSwitcher />, we need to create an effect that runs any time our theme or auto modes have changed and call our API. Note that I’m also tracking the source of truth for “auto” mode with a data attribute attached to the <html> element.

Here’s an example effect in React. Take special note of the highlighted lines regarding the “auto” mode:

ThemeSwitcher.tsx
export function ThemeSwitcher() {
  const [theme, setTheme] = React.useState(document.documentElement.classList.contains('dark') ? 'dark' : 'light');
  const [auto, setAuto] = React.useState(document.documentElement.dataset.autoTheme === 'true');
 
  React.useEffect(() => {
    document.documentElement.dataset.autoTheme = auto ? 'true' : 'false';
 
    if (theme === 'dark') {
      document.documentElement.classList.add('dark');
    } else {
      document.documentElement.classList.remove('dark');
    }
 
    save(theme, auto);
  }, [theme, auto]);
 
  // ...
  // there’s more needed here, this is just for demonstration purposes
  // ...
}

This results in our first two steps:

  1. The theme is tracked on the <html> element, using the className and data- attribute: <html class="dark" data-auto-theme="true">
  2. Our API (which we’ll cover later) is called every time the user setting changes manually.

Prefers color scheme media query

Now that the basic plumbing is set up and we have manual tracking, we need to make sure that when theme switching is set to “auto” mode that we start respecting the prefers-color-scheme media query using the matchMedia API.

Follow the comments inline for any needed explanations.

ThemeSwitcher.tsx (partial)
React.useEffect(() => {
  if (!auto) {
    // do nothing if not in auto mode.
    return;
  }
 
  // On change to auto-mode, set the theme based on the current media query preference
  const isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
  setTheme(isDark ? 'dark' : 'light');
 
  // Add an event listener any time the device requests a change to the color scheme
  function listener(event: MediaQueryListEvent) {
    setTheme(event.matches ? 'dark' : 'light');
  }
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', listener);
 
  // Return a cleanup function when unloading so we stop listening to the media change
  return () => {
    window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', listener);
  };
}, [auto]);

That should be it for our theme switcher. Your implementation is really up to you; whether you’re using a dropdown, button, or some other UI is up to you. I’m going to gloss over all of that in favor of getting into the trickier parts of this article. If you’d like an example, you can always check out my Solid-js implementation: ThemeSwitcher.tsx.


Edge functions

As mentioned previously, we’re going to use Netlify’s Edge Functions to handle the server-side portion of our requests.

API endpoint

First, let’s write our little cookie-setting API as a Netlify Edge Function. To start accepting calls to a URL, edit the Netlify config and add an edge_functions declaration. In my case, I put the pathname as /api/theme/ and I’ll place the code in netlify/edge-functions/set-theme.ts:

netlify.toml
[[edge_functions]]
	path = "/api/theme/*"
	function = "set-theme"

Now that we have declared our endpoint, let’s actually write it to save the user’s theme choice as a server-side secure cookie:

netlify/edge-functions/set-theme.ts
import type { Context } from 'https://edge.netlify.com/';
 
export default async (req: Request, context: Context) => {
  const url = new URL(req.url);
  const params = url.searchParams;
 
  // Set a server-side secure cookie with the requested URL search params
  context.cookies.set({
    name: 'theme',
    value: params.toString(),
    path: '/',
    secure: true,
    httpOnly: true,
    sameSite: 'Strict',
    // Set a *REALLY* big expires value
    expires: new Date(Date.now() + 2_592_000_000),
  });
 
  // Respond with a success JSON payload
  return new Response(JSON.stringify({ error: false }), {
    status: 200,
    headers: {
      'content-type': 'application/json',
    },
  });
};

Well, astute reader… did you notice that I didn’t add any error handling here? Technically, someone could try to set any theme they want, even if it were invalid.

Let’s make sure we handle that both if a theme setting is not sent and if the theme is invalid:

netlify/edge-functions/set-theme.ts
import type { Context } from 'https://edge.netlify.com/';
 
const themes = ['light', 'dark'] as const;
 
export default async (req: Request, context: Context) => {
  const url = new URL(req.url);
  const params = url.searchParams;
 
  // Didn't include a default or fallback theme
  if (!params.has('theme')) {
    return new Response(JSON.stringify({ error: 'Missing theme parameter' }), {
      status: 400,
      headers: {
        'content-type': 'application/json',
      },
    });
  }
 
  const theme = params.get('theme') as typeof themes[number];
  const auto = params.get('auto') === 'true';
 
  // Sent a theme that's not valid for our site?
  if (!themes.includes(theme)) {
    return new Response(JSON.stringify({ error: `Invalid theme: ${theme}` }), {
      status: 400,
      headers: {
        'content-type': 'application/json',
      },
    });
  }
 
  context.cookies.set({
    name: 'theme',
    value: params.toString(),
    path: '/',
    secure: true,
    httpOnly: true,
    sameSite: 'Strict',
    expires: new Date(Date.now() + 2_592_000_000),
  });
 
  return new Response(JSON.stringify({ error: false }), {
    status: 200,
    headers: {
      'content-type': 'application/json',
    },
  });
};

Rewriting static HTML responses

Next, again, we’ll take a hint from our inspiration article and rewrite part of our HTML responses for the static-generated multi-page site using Edge Functions. However, this is where we start deviating a lot more:

Accepting Client Hint headers

Since we want to be able to allow users to select an “auto” select for the “light” or “dark” mode, we need a way for the browser to tell us ahead of time what it actually wants on page load. In comes a new standard, Client Hint Headers.

Client hints are a newer set of HTTP request headers that enable a server to request more information from a browser about the device, network, user, and user-agent specific preferences. In particular, we’re interested in the prefers-color-scheme client hint, which will instruct browsers to send back to our server the same data that would normally only be available in CSS media queries.

To get started accepting Client Headers, we need our HTTP responses to add an accept-ch header with each hint that we’re asking for. Since we only need one, this is fairly short to add to the Netlify _headers file:

_headers
/*
	accept-ch: sec-ch-prefers-color-scheme

Now that we’re requesting the client hints, we need to actually do something with them. First, let’s start with a barebones edge function that merely:

netlify/edge-functions/theme.ts
import type { Context } from 'https://edge.netlify.com/';
import { HTMLRewriter, Element } from 'https://ghuc.cc/worker-tools/html-rewriter/index.ts';
 
export default async (req: Request, context: Context) => {
  // Get the default response for this URL. This will grab the actual response as if we were not running this function
  const res = await context.next();
 
  // Check the content type. If we're not serving HTML, running the rest of this function will be a waste of compute time and power
  const type = res.headers.get('content-type');
  if (!type?.startsWith('text/html')) {
    return;
  }
 
  return res;
};

Next, we need to try to read the cookie and fall back on a default value if it’s not available. Using nullish-coalescing, we can make this fairly simple:

const rawCookie = context.cookies.get(COOKIE_NAME) ?? 'auto=true&theme=light';
const params = new URLSearchParams(rawCookie);
const isAuto = params.get('auto') === 'true';
const theme = params.get('theme') || 'light';

But the whole point of the auto setting on our theme switcher is that we want to read from the prefers-color-scheme client hint. So we’ll modify this block just a smidge to read the header and if the cookie states that auto-mode is preferred, we’ll use the header value if it’s available!

const prefers = req.headers.get('sec-ch-prefers-color-scheme');
const rawCookie = context.cookies.get(COOKIE_NAME) ?? 'auto=true&theme=light';
const params = new URLSearchParams(rawCookie);
const isAuto = params.get('auto') === 'true';
const theme = isAuto && prefers ? prefers : params.get('theme') || 'light';

Lastly, just like in the inspiration from “Learn with Jason”, we’ll use the HTMLRewriter and update the className and data-auto-theme attributes on our <html> element. All-together, our function looks like this:

netlify/edge-functions/theme.ts
import type { Context } from 'https://edge.netlify.com/';
import { HTMLRewriter, Element } from 'https://ghuc.cc/worker-tools/html-rewriter/index.ts';
 
export default async (req: Request, context: Context) => {
  const res = await context.next();
  const type = res.headers.get('content-type');
  if (!type?.startsWith('text/html')) {
    return;
  }
 
  const prefers = req.headers.get('sec-ch-prefers-color-scheme');
  const rawCookie = context.cookies.get('theme') ?? 'auto=true&theme=light';
  const params = new URLSearchParams(rawCookie);
  const isAuto = params.get('auto') === 'true';
  const theme = isAuto && prefers ? prefers : params.get('theme') || 'light';
 
  return new HTMLRewriter()
    .on('html', {
      element(element: Element) {
        const original = element.getAttribute('class') || false;
        element.setAttribute('class', [original, theme].filter(Boolean).join(' '));
        element.setAttribute('data-auto-theme', isAuto ? 'true' : 'false');
      },
    })
    .transform(res);
};

Now we actually need to enable the edge function. In our netlify.toml, like we did before for the set-theme function3:

netlify.toml
[[edge_functions]]
  path = "/*"
  function = "theme"

And that’s it! You can try it out on the page that you’re currently reading! Just click the theme switcher button in the site header to something different, reload or browse around the site and be amazed as there is no more FART on a statically generated HTML site!

Downsides

First and foremost: Client Hints are still considered “experimental” and only available in Chromium-based browsers. For anyone not using one of those, the auto-theme selection won’t work and the theme setting edge function will fall back on the last known theme value per the cookie. That fallback should be mostly okay, but still a bummer.

Secondly, Client Hints are not provided by the browser to the server on first load. While there are ongoing discussions about allowing this to happen, there are technical challenges in making browsers first check which hints are being requested in order to send them. All this means that the even if you’re using a browser that supports Client Hints, your first page load to the site will always have a FART.

We could work around that by forcing a redirect/reload on first load, but that would require quite a bit of extra work that is frankly not worth the effort.


Updates

I’ve made some changes to how things are done since originally posting this. Hopefully I’ll remember to keep things up to date:

  • : Switched the theme tracking from the body element to the documentElement/html element. This fixes an issue in some mobile browsers where they showed a white background at the bottom of a full-page scroll by moving the background color classNames up from a wrapping div element and onto the body element – then moving the dark className up to the html element.

Footnotes

  1. I tried to avoid using this silly acronym in the title, but it is kinda fun. Sorry if you’re too mature for 💨 jokes, because I’m not.

  2. Client hints are currently only supported by Chromium-based browsers. This is a huge shame, but hopefully more pressure will be put on other browsers to get traction soon. See the section on downsides for more information.

  3. Even though we’re ejecting early from our HTML rewriting edge function if the response does not have the text/html content type, there is still a limited number of edge function runs on the Netlify free plan. Yes, the limit is a big number, currently 3 million, but, let’s do our best not to get hit by any surprises.

    I’ve split up my edge function into only running on known HTML page paths netlify.toml#L11-L35. I would have liked to have seen better path matching or a way to do an exclude-list along with a path glob, but alas, that feature does not exist yet.