How to implement Dark Mode in Remix

Posted on 05/09/2023

18 min read


Introduction

Nowadays when we are working on a new app or web we usually design with dark mode as default or we think of having that as an out-of-the-box feature, but having dark mode is not only a cool feature:

Dark mode is also a valuable accessibility feature. Some types of visual impairment can make it painful to look at bright colours, or large blocks of white might wash over the black text. Some people with dyslexia or Irlen’s Syndrome can struggle to read black text on a white background.

Quote taken from Mobile A11y.

So, the problem we aim to address here is how to implement dark mode in Remix.

Styling

Since this post focuses on adding dark mode to Remix, we won't delve deep into CSS:

[data-theme='light'] {
  color-scheme: light;
}

[data-theme='dark'] {
  filter: invert(1) hue-rotate(180deg);
}

This example does not take into account real accessibility, for a best practices implementation you can check: Tailwind

Tailwind caveat: to make it work with this blog post you should change over to class method of dark mode and set the name to data-theme.

Another really good alternative is Daisyui

Approaches to implement the theme toggle

When it comes to implement a way to the user to toggle the theme to either mode, you have two options:

  • Save the theme to the LocalStorage, override the data-theme attribute in the html tag and subscribe it to the changes to make the switch instantly.
  • Save the theme into a cookie and make the server do the change for you.

I will cover only the second option since we are using Remix, we want to take all the benefits of server-side rendering and try to ship the least possible amount of javascript to the client.

Getting started

Let's start by creating the cookie helper that will be used to store and get the theme preferences:

app/services/theme.server.js

import { createCookie } from '@remix-run/node'

export const themeCookie = createCookie('theme', {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production'
})

export async function getThemeFromCookies(request: Request) {
  const theme = await themeCookie.parse(request.headers.get('Cookie'))
  return theme || 'system'
}

the getThemeFromCookies function is a quick helper that will be useful to get the theme preferences in the next step.

Adding the theme to the html tag

Now we need to create a loader to bring up cookie value of the theme preference and set it to the html tag:

app/root.jsx

import { useMemo } from 'react'
import { useLoaderData } from '@remix-run/react'
import { json} from '@remix-run/node'
import { getThemeFromCookies } from './services/theme.server'

export const loader = async ({ request }) => {
  const theme = await getThemeFromCookies(request)

  return json({ theme })
}

export default function App() {
  const { theme } = useLoaderData()

  const htmlProps = useMemo(() => {
    return {
      lang: 'en',
      'data-theme': theme === 'system' ? undefined : theme,
    }
  }, [theme])

  return (
    <html {...htmlProps}>
      (...)
    </html>
   )
}

We check if the theme cookie has a value, if it is not the case we just use the system preferences, otherwise we set the data-theme attribute with the user preference

Adding endpoint to switch preferences

This can be done either in an action on the root but I personally like to have them in a different resource route:

app/routes/prefereces.theme.js

import { redirectBack } from 'remix-utils'

import { themeCookie } from '../services/theme.server'

export const action = async ({ request }) => {
  const form = await request.formData()
  const theme = form.get('theme')

  return redirectBack(request, {
    fallback: '/',
    headers: {
      'Set-Cookie': await themeCookie.serialize(theme)
    }
  })
}

Here we take the theme that the user want to set as preference from the request's form data and make a redirect back with the util function of sergiodxa's awesome library remix-utils that sends the user back to where he/she was.

Create Toggle component

Last but not least we need to create a component that when being clicked it toggles the theme:

app/components/ToggleThemeButton.jsx

import { Form } from '@remix-run/react'

export default function ToggleThemeButton({ currentTheme }) {
  const themeToToggleTo = currentTheme === 'dark' ? 'light' : 'dark'

  return (
    <Form action="/preferences/theme" method="POST">
      <input type="hidden" name="theme" value={themeToToggleTo} />
      <button>{currentTheme === 'dark' ? 'switch to light mode' : 'switch to dark mode'}</button>
    </Form>
  )
}

We add the opposite of the current theme value as a hidden input so that when the user clicks on the button, the page submits to the resource route we created previously with the theme correct theme value to change to.

And that's it, we have a fully server-side theme toggle for our Remix web app, a nice to have would be instead of a button, to have a dropdown to select between "System", "Light mode", and "Dark mode"