Skip to content

Dark Mode with Remix: Key Lessons ​

Today, thanks to tools like Tailwind, we can easily implement Dark Mode in our application. Now, seeking the best user experience with this functionality (Dark Mode) is another matter. Here is where Remix shines, giving you full control of the user experience from Backend to Frontend.

INFO

This post assumes a familiarity with ReactJS, Tailwind, and Remix. I won't be detailing every single step, but rather focusing on the relevant insights I gained during the implementation.

What do I mean by "the best user experience"? ​

The requirements I consider (it is a personal opinion) for achieving a better experience with Dark Mode are:

  1. The first time the user accesses the page, the server must send the page in Dark or Light mode, depending on the user's computer settings at that time.

WARNING

Otherwise, the user would experience a flash in the application, which would occur because the server initially sends the page with one theme, but then the application detects a different theme on the user's computer and makes the switch. As shown below:

  1. If the user doesn't select any mode, the page will switch to Dark or Light mode when the user changes their computer's mode.
  1. If the user selects a mode, the page will switch to that mode, but if they change the mode on their computer, it will not affect the page.
  1. If the user selects the System mode, the mode will be changed to the one currently set on the user's computer. And if the user changes the mode on their computer, it will affect the page.

First Requirement ​

To fulfill the first requirement, we need to somehow ascertain the mode selected by the user on their computer before serving the page from the server. This is something I understand cannot be achieved, as the server is unaware of the user's selections on their computer.

So, how do we address this? ​

The trick I learned to solve this issue is to render a <script> tag within a component that enables the determination of the user's Mode selection and triggers a page reload. When the page reloads, this <script> will have already created a Cookie containing the user's mode information, allowing the server to access the Cookies and understand which Mode to serve the page in.

tsx
function ThemeMonitor() {
    return (
        <script dangerouslySetInnerHTML={{ __html: `
            console.log('Theme script is running');
            const allCookies = (document.cookie || "").split(";");
            const themeCookie = allCookies.find((cookie) => cookie.trim().startsWith("theme="));
            if (!themeCookie && navigator.cookieEnabled) {
              const themeDetected = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
              document.cookie = 'theme=' + JSON.stringify({ detected: themeDetected, selected: "" }) + ';path=/';
              window.location.reload();
            }
			`,
    }}
    />
);
}
function ThemeMonitor() {
    return (
        <script dangerouslySetInnerHTML={{ __html: `
            console.log('Theme script is running');
            const allCookies = (document.cookie || "").split(";");
            const themeCookie = allCookies.find((cookie) => cookie.trim().startsWith("theme="));
            if (!themeCookie && navigator.cookieEnabled) {
              const themeDetected = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
              document.cookie = 'theme=' + JSON.stringify({ detected: themeDetected, selected: "" }) + ';path=/';
              window.location.reload();
            }
			`,
    }}
    />
);
}

And then we can use that component in the <head> of our root.tsx

html
 <html>
    <head>
     <ThemeMonitor />
     <!--more tags here...-->
    </head>
    <!--more tags here...-->
 </html>
 <html>
    <head>
     <ThemeMonitor />
     <!--more tags here...-->
    </head>
    <!--more tags here...-->
 </html>

This trick allows us to detect the user's mode before they see the rendered page 😎.

The value stored in the Cookie is an object that I will delve into further later, but it has the following structure:

ts
 const theme = {detected: 'dark', selected: ''}
 const theme = {detected: 'dark', selected: ''}

Second Requirement ​

It was a pleasant surprise to discover how to address this requirement. Truth be told, I had no clue that it was possible to detect in the browser when a user switched their mode on their computer.

I leveraged prefers-color-scheme to address this. This innovative feature allows websites to adapt seamlessly to the user's preferred color mode on their operating system or browser. By detecting whether a user has opted for a light or dark color mode, websites can tailor their visual appearance accordingly, enhancing readability and overall browsing experience. Example:

css
 @media (prefers-color-scheme: dark) {
    /* Styles for Dark Mode */
    body {
        background-color: #1a1a1a;
        color: #ffffff;
    }
}
 @media (prefers-color-scheme: dark) {
    /* Styles for Dark Mode */
    body {
        background-color: #1a1a1a;
        color: #ffffff;
    }
}

Let's delve into how I implemented this functionality within the ThemeMonitor component.

tsx
function ThemeMonitor() {

 const { revalidate } = useRevalidator();

  useEffect(() => {
    const themeQuery = window.matchMedia("(prefers-color-scheme: dark)");
    function handleThemeChange() {
      const currentTheme = getTheme(document.cookie);
      document.cookie = commitTheme({
        ...currentTheme,
        detected: themeQuery.matches ? "dark" : "light",
      });
      revalidate();
    }
    themeQuery.addEventListener("change", handleThemeChange);
    return () => {
      themeQuery.removeEventListener("change", handleThemeChange);
    };
  }, [revalidate]);

    return <script dangerouslySetInnerHTML={{ __html: `***previous code here***`}} />
}
function ThemeMonitor() {

 const { revalidate } = useRevalidator();

  useEffect(() => {
    const themeQuery = window.matchMedia("(prefers-color-scheme: dark)");
    function handleThemeChange() {
      const currentTheme = getTheme(document.cookie);
      document.cookie = commitTheme({
        ...currentTheme,
        detected: themeQuery.matches ? "dark" : "light",
      });
      revalidate();
    }
    themeQuery.addEventListener("change", handleThemeChange);
    return () => {
      themeQuery.removeEventListener("change", handleThemeChange);
    };
  }, [revalidate]);

    return <script dangerouslySetInnerHTML={{ __html: `***previous code here***`}} />
}

Some relevant points to mention here are:

  1. useRevalidator: This hook provided by Remix allows us to revalidate the route without reloading the browser. πŸ’ͺ

Since the page receives the theme it needs to work with from the server, when revalidating the page using this hook, we have the updated data available:

tsx
export async function loader({ request }: LoaderArgs) {
    const theme = getTheme(request.headers.get("Cookie"));
    return json({ theme }); // {detected: 'dark', selected: 'light'}
}
export async function loader({ request }: LoaderArgs) {
    const theme = getTheme(request.headers.get("Cookie"));
    return json({ theme }); // {detected: 'dark', selected: 'light'}
}
  1. MatchMedia:matchMedia is a JavaScript API that enables responsive design by allowing you to query the browser about the current state of a specific CSS media query. It provides a way to programmatically detect the device's characteristics, such as screen width, orientation, and color scheme.

By creating a media query for the preferred color scheme ("dark"), I monitored changes to this preference using the change event. Whenever a change occurs, I update the detected theme in my Cookie and trigger a revalidate.

  1. Cookie: When you assign a cookie to the document using the format:
js
document.cookie = newCookie;
document.cookie = newCookie;

it doesn't delete existing cookies; instead, it sets or updates the specific cookie you are assigning. It's not intuitive, but that's the API we have 😩.

Third Requirement ​

To fulfill the third requirement, I employed a strategy that involved structuring data in a way that allowed me to determine both the detected theme and the user-selected theme on demand:

ts
 const theme = {detected: 'dark', selected: 'ligth'}
 const theme = {detected: 'dark', selected: 'ligth'}

This approach enables us to determine that the theme to be applied on the page will be:

ts
const data = useLoaderData<typeof loader>();
const theme = data.theme.selected || data.theme.detected;
const data = useLoaderData<typeof loader>();
const theme = data.theme.selected || data.theme.detected;

If the user has chosen a mode, then the evaluation result of data.theme.selected || data.theme.detected will be the selected theme. 😎

Requirement Four ​

If the user has chosen the System option:

the selected property will remain empty. As a result, the detected theme will be applied.

And well, that's it. πŸ˜€

Bibliography ​

I wouldn't have been able to learn these things if it weren't for Kent C. Dodds' Epic Stack and this incredible guide from Raj talks tech

Personal blog by Angel NΓΊnez