Dark mode using TailwindCSS and Turbo

Juraj Kostolanský August 9, 2023

I have been recently working on a new version of my project LocaleData. This included, among other improvements, a complete redesign of the interface and a dark mode option.

The CSS framework I’m using to style LocaleData - TailwindCSS - has a great built-in support for dark mode. It provides a dark variant that lets you style your site differently when dark mode is enabled.

The TailwindCSS framework also provides two options for toggling the appearance:

  1. Default (media) - This uses the prefers-color-scheme CSS media feature and toggles between light and dark mode based on the operating system setting, so no more work is required to support switching between them.
  2. Manual (class) - This allows manual toggling using a dark class in the HTML tree. This is the right option if you want to allow your users to manually select light or dark mode.

I wanted to have control over the selected mode, so I chose the second (manual) option. The implementation is quite easy - just add an option to choose the required appearance somewhere in the interface, save it to the database (or localStorage), and add a dark class to the HTML tree if the dark mode should be used, for example, to the html tag:

<%= tag.html class: class_names('dark' => Current.user&.dark_mode?) do %>
  <!-- ... -->
<% end %>

However, it wasn’t that easy in my case. I’m using Turbo in LocaleData, which updates the page on navigation without doing a full reload, and keeps the rest of the page intact, including the html tag. In that case, if the selected appearance in the database changes, the application won’t use the new setting without a manual page reload.

We could use a little workaround here by adding a bit of JavaScript into the body tag to handle the appearance toggling. This script will be run on every page, so it will pick the new value in the database without issues:

<html>
  <head>
    <!-- ... -->
  </head>
  <body>
    <!-- ... -->
    <script>
      <% if Current.user&.dark_mode? %>
        document.documentElement.classList.add('dark')
      <% else %> 
        document.documentElement.classList.remove('dark')
      <% end %> 
    </script>
  </body>
</html>

Unfortunately, when we refresh a page, this code will cause some flickering, because the script will be run after the whole page is rendered. If the user chooses a dark mode, the page will be shown with a light appearance, and then the dark class will be applied with a delay.

We could solve that by combining the previous approaches - provide the initial setting by adding the dark class to the html tag using Ruby on Rails and apply any changes on each page navigation:

<%= tag.html class: class_names('dark' => Current.user&.dark_mode?) do %>
  <head>
    <!-- ... -->
  </head>
  <body>
    <!-- ... -->
    <script>
      <% if Current.user&.dark_mode? %>
        document.documentElement.classList.add('dark')
      <% else %> 
        document.documentElement.classList.remove('dark')
      <% end %> 
    </script>
  </body>
<% end %>

There is also another solution: Turbo provides a useful annotation data-turbo-track="reload". This tells Turbo to track the HTML of the element and performs a full page reload when it changes. So we can slightly simplify things by moving the code to the head tag and using this Turbo annotation to track changes of the user appearance setting:

<%= tag.html class: class_names('dark' => Current.user&.dark_mode?) do %>
  <head>
    <!-- ... -->
    <script data-turbo-track="reload">
      // Appearance: <%= Current.user&.dark_mode? ? 'dark' : 'light' %>
    </script>
  </head>
  <body>
    <!-- ... -->
  </body>
<% end %>

In this case, when the appearance setting in the database changes, Turbo will notice it on page navigation and force a full page reload to reflect that change.

We can also alter the code a little and replace the initial dark class on the html tag with some JavaScript:

<html>
  <head>
    <!-- ... -->
    <script data-turbo-track="reload">
      <% if Current.user&.dark_mode? %>
        document.documentElement.classList.add('dark')
      <% end %>
    </script>
  </head>
  <body>
    <!-- ... -->
  </body>
</html>

Because the script tag is inside head, the JavaScript code will be run early, without causing any flickering issues.

The next thing we need to solve is to support guests. Because anonymous users don’t have a LocaleData profile, they cannot choose the appearance setting. In this case, we can use a fallback to the prefers-color-scheme CSS media feature and use their system setting instead.

In this manner, we can also easily add a third option for the logged in users:

  1. Always use light mode
  2. Always use dark mode
  3. Switch mode automatically based on system settings

To do that, we need to use window.matchMedia to check the system settings, and also add an event listener (using addEventListener) to track changes in these settings.

The final solution can look like the code below. It could be further improved and simplified, but I’ll leave that up to you.

<html>
  <head>
    <!-- ... -->
    <script data-turbo-track="reload">
      <% if Current.user&.dark_mode? %>
        document.documentElement.classList.add('dark')
      <% elsif Current.user&.light_mode? %>
        document.documentElement.classList.remove('dark')
      <% else # auto appearance or guest %>
        if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
          document.documentElement.classList.add('dark')
        } else {
          document.documentElement.classList.remove('dark')
        }

        window.matchMedia('(prefers-color-scheme: dark)').addEventListener(
          'change',
          (e) => {
            if (e.matches) {
              document.documentElement.classList.add('dark')
            } else {
              document.documentElement.classList.remove('dark')
            }
          }
        )
      <% end %>
    </script>
  </head>
  <body>
    <!-- ... -->
  </body>
</html>

Let's stay in touch

Do you like what you read? Subscribe to get my content on web development, programming, system administration, side projects and more. No spam, unsubscribe at any time.