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:
- Default (
media
) - This uses theprefers-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. - Manual (
class
) - This allows manual toggling using adark
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:
- Always use light mode
- Always use dark mode
- 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>