8 September, 2024 | 19 min read

The Complete Guide to Dark Mode with Remix (2024)

In 2022, I wrote The Complete Guide to Dark Mode with Remix. A lot has changed in 2 years and the approach I described is no longer the one I use today.

It’s time for an update.

The approach I take now is taken from Kent C. Dodds’ Epic Stack. Let’s jump straight in.

Challenges With Dark Mode

To implement a complete dark mode solution, there are a couple of challenges we’re going to have to solve:

  • How are we going to remember a user’s preference after they update it? We need the information on the server so that SSR has the correct initial theme, but spinning up a database seems like overkill.
  • If the user hasn’t set a theme preference, how can we set a sensible default? Ideally we’d use their system settings, but reading that value requires JavaScript. If there’s a mismatch between our SSR value and the user’s system preference, the user will see a flash of the wrong theme before the JavaScript is hydrated.

Now that we know what we’re up against, let’s see the plan and why I favour this over my previous approach.

The Plan

Our ultimate goal is simple: if the user should be shown a dark theme, we want to add a dark class to the root HTML tag.

<html lang="en" class="dark">
  /* ... */
</html>

We can then apply our dark styles with either CSS variables

styles.css
html.dark {
  --text-colour: white;
  --background-colour: black;
}

… or Tailwind.

tailwind.config.ts
import type { Config } from 'tailwindcss';

export default {
  darkMode: 'class',
  // ...
} satisfies Config;

If the user updates their theme preference (e.g. by clicking the dark mode toggle), we’ll store that preference in a cookie 🍪.

If the user hasn’t manually updated their preference, we’re going to use a client hint cookie. This is inspired from a draft specification for browsers.

The server will check to see if the client hint cookie has been set. If it hasn’t, it will send a tiny bit of JavaScript that sets the cookie with the user’s system preference and reloads the page. This time the cookie is set, and the server can use this preference as the initial theme.

Request for site arrivesYesYesNoNoRequest contains a cookie with user's previously chosentheme preference?Request contains a client hint cookie?Use user preferencevalueUse client hintvalueSend script to checksystem preference, addto client hint cookie,and refresh the page

FAQ

Dependencies

The Epic Stack approach involves installing a few dependencies. Some of these are “necessary”, others are just useful because they’re already installed in the Epic Stack.

However, I appreciate that you might not want to add more dependencies to your project just to add dark mode. Here’s a quick summary of what we’ll be using and their alternatives.

  • @epic-web/client-hints. This is the most important one. The code used to be in the Epic Stack but Kent C. Dodds pulled it out into a separate dependency so that it could be easily reused.

    Alternative: If you really don’t want to install this dependency, the alternative would be to copy the code from the Github repo. It’s a small and simple repo so it shouldn’t be too much. You can even leave out the time-zone and reduced-motion parts if you only care about the theme.

  • cookie. Used to parse and serialise the HTTP Cookie header string.

    Alternative: Use the cookie utilities provided by Remix itself.

  • @epic-web/invariant. Used to throw errors (or error responses) when certain conditions aren’t met.

    Alternative: Either copy the code from the source code (it’s tiny). Or manually check if the condition has been met and throw an error (or error response) in that situation.

  • remix-utils. Allows us to conditionally render a hidden input only when being rendered on the server. We’ll use this to add some progressive enhancement at the end of the tutorial.

    Alternative: Either copy the code from the source code (again, it’s not a lot even though it’s spread across multiple files). Or don’t include the progressive enhancement (it only applies if the theme button is clicked before the page has been hydrated).

  • Conform and Zod. Our theme switch button is wrapped in a form which submits an action to the server. These dependencies validate the form data on the server and apply appropriate props to the form around the button. It makes sense to use it in the Epic Stack where Conform is used for all the forms. If this is the only place you’ll be using it though, I would suggest it isn’t worth it.

    Alternative: Get the form data values directly from the formData instance and validate it manually if you think it’s necessary.

With that out of the way, let’s start by setting the correct initial theme.

Setting the Initial Theme

The initial theme will come from the user preference cookie if the user has updated their theme. Otherwise, we’ll use a client hint cookie to find a sensible default.

I like to start with imagining a brand new user visiting the page.

  1. First, we’d check if they have set the user preference cookie, which they haven’t.
  2. Second, we’d check if the client hint cookie has been set. Because this is the very first time they’ve visited our page, it hasn’t yet been set.
  3. Send a script to read the user’s system preferences, set the values in a cookie, and then refresh the page.

Let’s work our way from the bottom step up.

Create a new file, utils/client-hints.tsx and create a component which will render the script tag. Fortunately, @epic-web/client-hints will do most of the heavy lifting here.

utils/client-hints.tsx
/**
 * This file contains utilities for using client hints for user preference which
 * are needed by the server, but are only known by the browser.
 */
import { getHintUtils } from '@epic-web/client-hints';
import { clientHint as colourSchemeHint } from '@epic-web/client-hints/color-scheme';
 
const hintsUtils = getHintUtils({ theme: colourSchemeHint });

/**
 * @returns inline script element that checks for client hints and sets cookies
 * if they are not set then reloads the page if any cookie was set to an
 * inaccurate value.
 */
export function ClientHintCheck() {
  return (
    <script
      dangerouslySetInnerHTML={{
        __html: hintsUtils.getClientHintCheckScript(),
      }}
    />
  );
}

Create hintsUtils by passing the desired client hints to getHintUtils. We’re just using theme but it can be extended to include anything you want client hint cookies for.

We can then get the contents of the script tag with hintsUtils.getClientHintCheckScript.

Now that we have the script, we want to render it at the top of the head tag.

root.tsx
import { ClientHintCheck } from './utils/client-hints';

export function Layout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <ClientHintCheck />
        {/* Rest of head */}
      </head>
      <body>
        {/* ... */}
      </body>
    </html>
  );
}

FAQ

Using the Client Hook to Set the Theme

Now we have our client hook set, let’s use it to render the correct theme.

Let’s update utils/client-hints.tsx with a function which takes a request, reads the client hints cookie and returns the client hints. Luckily we can once again take advantage of the @epic-web/client-hints package.

utils/client-hints.tsx
const hintsUtils = getHintUtils({ theme: colourSchemeHint });

export const { getHints } = hintsUtils;

Then we’ll send this over to the client using the root loader.

root.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { getHints } from './utils/client-hints';

export async function loader({ request }: LoaderFunctionArgs) {
  return json({
    requestInfo: {
      hints: getHints(request),
    },
  });
}

To set the theme class on our HTML, we’ll create a useTheme hook, which will return 'light' or 'dark' depending on the current theme.

Let’s create a new route file for this, routes/resources.theme-switch.tsx.

routes/resources.theme-switch.tsx
import { useHints } from '#app/utils/client-hints.tsx'

/**
 * @returns the client hint theme.
 */
export function useTheme() {
  const hints = useHints();
  return hints.theme;
}

You’ll notice we are using a useHints hook which we haven’t created yet, so let’s create that now.

Our hook will get be getting the client hint from the requestInfo value in the root route’s loader data. We’re going to need to access this object in several places as we add more to it later, so let’s create a utility hook to access the requestInfo data.

utils/request-info.ts
import { invariant } from '@epic-web/invariant'
import { useRouteLoaderData } from '@remix-run/react'
import { type loader as rootLoader } from '#app/root.tsx'

/**
 * @returns the request info from the root loader
 */
export function useRequestInfo() {
  const data = useRouteLoaderData<typeof rootLoader>('root')
  invariant(data?.requestInfo, 'No requestInfo found in root loader')

  return data.requestInfo
}

We want to be able to use this hook in any file, not just those that have the root’s loader as their loader. So we’ll use useRouteLoaderData to access the root’s loader data, regardless of what route we’re in.

TypeScript types this data as | undefined because there’s nothing stopping us passing in any string value. However, if the root’s loader data is not available or if the root loader isn’t returning requestInfo, something exceptional has gone wrong. So we use invariant to throw an error in this case and guarantee our return value is always defined.

Now let’s go back to our utils/client-hints.tsx file and define our useHints hook.

utils/client-hints.tsx
import { useRequestInfo } from './request-info';

/**
 * @returns an object with the client hints and their values
 */
export function useHints() {
  const requestInfo = useRequestInfo();
  return requestInfo.hints;
}

Now our useTheme will return either 'light' or 'dark' using the client hint cookie.

Finally, let’s use this hook to set the correct class on our HTML element.

There’s just one small problem. Most apps have an “app shell” which contains the html, head, etc. The “app shell” is usually shared between the Root route component and the root ErrorBoundary. Newer versions of Remix even make this easier by supporting a Layout export.

root.tsx
import {
  Links,
  LiveReload,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

export function Layout({ children }) {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1"
        />
        <Meta />
        <Links />
      </head>
      <body>
        {/* children will be the root Component, ErrorBoundary, or HydrateFallback */}
        {children}
        <Scripts />
        <ScrollRestoration />
        <LiveReload />
      </body>
    </html>
  );
}

export default function App() {
  return <Outlet />;
}

export function ErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    return (
      <>
        <h1>
          {error.status} {error.statusText}
        </h1>
        <p>{error.data}</p>
      </>
    );
  }

  return (
    <>
      <h1>Error!</h1>
      <p>{error?.message ?? "Unknown error"}</p>
    </>
  );
}

However, getting the theme requires reading from the loader data, which we can’t guarantee was successful in the ErrorBoundary.

Therefore, instead of using a Layout export, we’ll have to manually share the app shell between the root exports. We can then give this component optional props to pass in the loader data if it’s available. If no value is passed (e.g. if the loader threw an error), we’ll default to 'light'.

root.tsx
import {
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from '@remix-run/react'
import { GeneralErrorBoundary } from './components/error-boundary.tsx'
import { useTheme } from './routes/resources/theme-switch.tsx'
import { ClientHintCheck } from './utils/client-hints.tsx'
import { type Theme } from './utils/theme.server.ts'

function Document({
  children,
  theme = 'light',
}: {
  children: React.ReactNode;
  theme?: Theme;
}) {
  return (
    <html lang="en" className={theme}>
      <head>
        <ClientHintCheck />
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

export default function App() {
  const theme = useTheme();

  return (
    <Document theme={theme}>
      <Outlet />
    </Document>
  );
}

export function ErrorBoundary() {
  return (
    <Document>
      <GeneralErrorBoundary />
    </Document>
  );
}

And there we go, the user now sees their system preference as the theme 🎉

You may have noticed we’re importing Theme from a file we haven’t defined yet. So we’ll create utils/theme.server.ts. This will be where we manage our user preference theme cookie. For now though, it’ll just be where we define the Theme type.

utils/theme.server.ts
export type Theme = 'light' | 'dark';

Next step, let’s give them a button to update it.

FAQ

Creating the Theme Switch

Let’s jump back to routes/resources.theme-switch.tsx.

For most forms in Remix, when they make a post it’ll go to the route’s action. This creates a dependency between our component and the route it is placed in.

For most components, that’s no problem - the route typically defines the features and the components are typically tied to a feature. However, sometimes we want our components to have a specific piece of functionality but not be tied to a single route.

Kent C. Dodds calls this pattern full stack components and it takes advantage of Remix’s resource routes. It’s a bit like creating a separate endpoint just for our theme switch. So now, instead of posting to the default action, we can use this route’s action.

routes/resources.theme-switch.tsx
import { useForm, getFormProps } from '@conform-to/react'
import { useFetcher } from '@remix-run/react'
import { Icon } from '#app/components/ui/icon.tsx'
import { type Theme } from '#app/utils/theme.server.ts'

export function ThemeSwitch({
  userPreference,
}: {
  userPreference?: Theme | null;
}) {
  const fetcher = useFetcher<typeof action>();

  const [form] = useForm({
    id: 'theme-switch',
    lastResult: fetcher.data?.result,
  });

  const mode = userPreference ?? 'system';
  const nextMode =
    mode === 'system' ? 'light' : mode === 'light' ? 'dark' : 'system';
  const modeLabel = {
    light: (
      <Icon name="sun">
        <span className="sr-only">Light</span>
      </Icon>
    ),
    dark: (
      <Icon name="moon">
        <span className="sr-only">Dark</span>
      </Icon>
    ),
    system: (
      <Icon name="laptop">
        <span className="sr-only">System</span>
      </Icon>
    ),
  }

  return (
    <fetcher.Form
      method="POST"
      {...getFormProps(form)}
      action="/resources/theme-switch"
    >
      <input type="hidden" name="theme" value={nextMode} />
      <div>
        <button type="submit">
          {modeLabel[mode]}
        </button>
      </div>
    </fetcher.Form>
  );
}

Our switch has three modes: light, dark, and system. We’ll use the user’s preference as the initial value but default to system if it isn’t defined.

We then render a form with a hidden input defining the next mode. In this example, our switch just cycles through all the values when clicked.

When the button is clicked, it submits the surrounding form to /resources/theme-switch which matches our file path. The next step is to create the action that will handle updating the user preference cookie.

Let’s add a function that will update the cookie with the new value.

utils/theme.server.ts
import * as cookie from 'cookie';

const cookieName = 'en_theme';
export type Theme = 'light' | 'dark';

export function setTheme(theme: Theme | 'system') {
  if (theme === 'system') {
    return cookie.serialize(cookieName, '', { path: '/', maxAge: -1 });
  } else {
    return cookie.serialize(cookieName, theme, { path: '/', maxAge: 31536000 });
  }
}

If the new theme is system, we set the new maxAge to -1, which will remove the cookie and we’ll default to using the client hint value.

Now that we can set the cookie value, let’s create the action to do so.

routes/resources.theme-switch.tsx
import { parseWithZod } from '@conform-to/zod';
import { invariantResponse } from '@epic-web/invariant';
import { json, type ActionFunctionArgs } from '@remix-run/node';
import { z } from 'zod';
import { setTheme } from '~/utils/theme.server';

const ThemeFormSchema = z.object({
  theme: z.enum(['system', 'light', 'dark']),
});

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const submission = parseWithZod(formData, { schema: ThemeFormSchema });

  invariantResponse(submission.status === 'success', 'Invalid theme received');

  const { theme } = submission.value;

  const responseInit = {
    headers: { 'set-cookie': setTheme(theme) },
  };
  return json({ result: submission.reply() }, responseInit);
}

We take the formData from the request, validate it with Conform and then set the cookie to the new theme value.

Now that we have the cookie value set, there’s two things left to do:

  1. Return the cookie value from our root loader
  2. Update useTheme to use this value if it’s defined.
utils/theme.server.ts
import * as cookie from 'cookie';

const cookieName = 'en_theme';
export type Theme = 'light' | 'dark';

export function getTheme(request: Request): Theme | null {
  const cookieHeader = request.headers.get('cookie');
  const parsed = cookieHeader
    ? cookie.parse(cookieHeader)[cookieName]
    : 'light';
  if (parsed === 'light' || parsed === 'dark') return parsed;
  return null;
}
root.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { getHints } from './utils/client-hints.tsx';
import { getTheme } from './utils/theme.server.ts';

export async function loader({ request }: LoaderFunctionArgs) {
  return json({
    requestInfo: {
      hints: getHints(request),
      userPrefs: {
        theme: getTheme(request),
      },
    },
  });
}
routes/theme-switch.tsx
import { useHints } from '~/utils/client-hints';
import { useRequestInfo } from '~/utils/request-info';

/**
 * @returns the user's theme preference, or the client hint theme if the user
 * has not set a preference.
 */
export function useTheme() {
  const hints = useHints();
  const requestInfo = useRequestInfo();
  return requestInfo.userPrefs.theme ?? hints.theme;
}

And we’re done! We have a working, persisted dark mode 🎉🎉🎉

There are a few more optimisations we can make though for the best user experience:

  • Optimistic updates
  • Subscribing to theme preference changes
  • Progressive Enhancement

FAQ

Optimistic Updates

When you click a dark mode toggle, you expect the page to update immediately. However, at the moment our styling won’t update until after the server has responded with a new cookie value. This might not be too bad with a good network connection, but it’ll be a pretty horrid experience if not!

Fortunately, Remix gives us tools to update the theme optimistically. This means we can update the theme as if we had already received a success response from the server.

While our request is in flight, Remix exposes the fetcher with useFetcher/useFetchers. We can use this fetcher to get the formData that was submitted, which includes the value of the next mode.

Let’s go back to our resources/theme-switch.tsx file and create a new hook, useOptimisticThemeMode.

resources/theme-switch.tsx
import { parseWithZod } from '@conform-to/zod';
import { useFetchers } from '@remix-run/react';

/**
 * If the user's changing their theme mode preference, this will return the
 * value it's being changed to.
 */
export function useOptimisticThemeMode() {
  const fetchers = useFetchers();
  const themeFetcher = fetchers.find(
    (f) => f.formAction === '/resources/theme-switch'
  );

  if (themeFetcher && themeFetcher.formData) {
    const submission = parseWithZod(themeFetcher.formData, {
      schema: ThemeFormSchema,
    });

    if (submission.status === 'success') {
      return submission.value.theme;
    }
  }
}

We use useFetchers to access all the current fetchers, and then filter them to the one with the action equal to our resource route endpoint. If this fetcher exists, we parse the form data with Conform and return the value.

Now, we can update useTheme to prioritise this value if a request is in progress. If the next mode is system, we’ll fall-back to the client hint value.

resources/theme-switch.tsx
import { useHints } from '~/utils/client-hints';
import { useRequestInfo } from '~/utils/request-info';

/**
 * @returns the user's theme preference, or the client hint theme if the user
 * has not set a preference.
 */
export function useTheme() {
  const hints = useHints();
  const requestInfo = useRequestInfo();
  const optimisticMode = useOptimisticThemeMode();
  if (optimisticMode) {
    return optimisticMode === 'system' ? hints.theme : optimisticMode;
  }
  return requestInfo.userPrefs.theme ?? hints.theme;
}

We also need to remember to update the ThemeSwitch component to use this optimistic value too.

resources/theme-switch.tsx
export function ThemeSwitch({
  userPreference,
}: {
  userPreference?: Theme | null
}) {
  // ...

  const optimisticMode = useOptimisticThemeMode()
  const mode = optimisticMode ?? userPreference ?? 'system'

  // ...
}

Now our users will see the styling update immediately, even if their network speed is slow!

Subscribe to Theme Preference Changes

At the moment, our client hint cookie is setting its value to the user’s colour scheme preference. But what if the user updates their preference after we’ve set the cookie?

Fortunately, @epic-web/client-hints gives us an easy way to update the cookie value and revalidate the page whenever it detects that the system preference has updated.

utils/client-hints.tsx
import { subscribeToSchemeChange } from '@epic-web/client-hints/color-scheme';
import { useRevalidator } from '@remix-run/react';
import * as React from 'react';

/**
 * @returns inline script element that checks for client hints and sets cookies
 * if they are not set then reloads the page if any cookie was set to an
 * inaccurate value.
 */
export function ClientHintCheck() {
  const { revalidate } = useRevalidator();
  React.useEffect(
    () => subscribeToSchemeChange(() => revalidate()),
    [revalidate]
  );

  return (
    <script
      dangerouslySetInnerHTML={{
        __html: hintsUtils.getClientHintCheckScript(),
      }}
    />
  );
}

Progressive Enhancement

As a final optimisation, let’s take a look at what happens when the user tries to change the theme before JavaScript has hydrated the page.

Instead of making a fetch request, the app is now making a native POST request. The default behaviour for this is to redirect the user to the route that was POST-ed to - in this case /resources/theme-switch.

Let’s instead detect if we’re making the request before the page has hydrated, and if we are, redirect them to the path they were already on.

First, we’ll update our root loader to send the current path (if JavaScript hasn’t hydrated, we won’t have access to hooks like useLocation).

root.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { getHints } from './utils/client-hints.tsx';
import { getTheme } from './utils/theme.server.ts';

export async function loader({ request }: LoaderFunctionArgs) {
  return json({
    requestInfo: {
      hints: getHints(request),
      path: new URL(request.url).pathname,
      userPrefs: {
        theme: getTheme(request),
      },
    },
  });
}

Then in our theme switch, if the page is rendering on the server, we’ll add an extra hidden input with the path. We’ll use ServerOnly from Remix Utils which will render the input on the server render, not after it has been hydrated on the client.

resources/theme-switch.tsx
import { getFormProps } from '@conform-to/react';
import { useFetcher } from '@remix-run/react';
import { ServerOnly } from 'remix-utils/server-only';
import { type Theme } from '~/utils/theme.server';

export function ThemeSwitch({
  userPreference,
}: {
  userPreference?: Theme | null;
}) {
  const fetcher = useFetcher<typeof action>();
  const requestInfo = useRequestInfo();

  // ...

  return (
    <fetcher.Form
      method="POST"
      {...getFormProps(form)}
      action="/resources/theme-switch"
    >
      <ServerOnly>
        {() => (
          <input type="hidden" name="redirectTo" value={requestInfo.path} />
        )}
      </ServerOnly>
      <input type="hidden" name="theme" value={nextMode} />
      <div>
        <button type="submit">
          {modeLabel[mode]}
        </button>
      </div>
    </fetcher.Form>
  );
}

Finally, we’ll update the action so that, if there is a redirectTo value, we know the request came before JavaScript hydrated. In this case, we’ll respond with a redirect to redirect the client back to the path they were already on.

resources/theme-switch.tsx
import { parseWithZod } from '@conform-to/zod';
import { invariantResponse } from '@epic-web/invariant';
import { json, redirect, type ActionFunctionArgs } from '@remix-run/node';
import { setTheme } from '~/utils/theme.server';

const ThemeFormSchema = z.object({
  theme: z.enum(['system', 'light', 'dark']),
  // this is useful for progressive enhancement
  redirectTo: z.string().optional(),
})

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const submission = parseWithZod(formData, { schema: ThemeFormSchema });

  invariantResponse(submission.status === 'success', 'Invalid theme received');

  const { theme, redirectTo } = submission.value;

  const responseInit = {
    headers: { 'set-cookie': setTheme(theme) },
  };
  if (redirectTo) {
    return redirect(redirectTo, responseInit);
  } else {
    return json({ result: submission.reply() }, responseInit);
  }
}

And just like that, our theme switch will still work without JavaScript, giving us some progressive enhancement.

Related Posts


Follow me on Twitter or Bluesky to get updates on new posts.