How we designed and implemented dark mode in Fern

How we designed and implemented dark mode in Fern

If you’re like us and are experiencing the phenomena known as winter, you know the nights are long, and daylight is in short supply. But that doesn’t mean we spend less time glued to our screens doing important work. And so, to make work life a little bit easier (on the eyes, at least), we decided to add dark mode support to Fern.

It’s no secret that dark mode has become a standard in interface design in recent years, and for good reasons. As mentioned above, dark mode is easier on the eyes and can significantly reduce eye strain while staring at our screens in the dark. It can also be aesthetically pleasing when done right (it’s not just a black background). Lastly, it can also help preserve battery life on your devices, but that’s probably not very relevant in the context of business tools.

However, implementing dark mode isn’t always easy. There are challenges in designing interfaces that look good both in light and dark modes and in making sure there is sufficient contrast in dark mode. More on that below.

Tailwind to the rescue

It feels like we started this project with a cheat code: the use of Tailwind in our front-end stack has simplified the implementation quite a bit because it has built-in system-level dark mode support. To add dark mode styling to any element, you simply need to add an extra class with their dark: prefix, and whenever dark mode is enabled on the system level, whether that’s manual or time-based, Tailwind will automatically display the appropriate styling.

For example, wherever you have text-black in your codebase, you add dark:text-white and Tailwind will render the text white when dark mode is enabled; pretty self-explanatory.

<div class="bg-white dark:bg-black">
	<p class="text-black dark:text-white">Look at this fine text!</p>
</div>

An example text in light and dark mode

Appropriating the existing color palette for dark mode

The first challenge we encountered was how to use our existing grayscale with dark mode. For all the primary colors in our design system (gray, primary, warning, error), we have an 11-step scale to make it as flexible as possible. Of course, we rarely use all colors in the scale, and we have defaults for common things like primary, secondary, and tertiary texts, backgrounds, borders, etc. Still, we like to have enough range if we need to make exceptions.

Our grayscale looks like this:

Fern's grayscale color palette

colors: {
  white: "#fff",
  gray: {
    25: "#FCFCFD",
    50: "#F9FAFB",
    100: "#F2F4F7",
    200: "#EAECF0",
    300: "#D0D5DD",
    400: "#98A2B3",
    500: "#667085",
    600: "#475467",
    700: "#344054",
    800: "#1D2939",
    900: "#101828",
  },
},

It’s easy to think that all you need to do is reverse the grayscale and call it a day: whenever you have text-gray-900, our primary text color, you would use text-gray-25. However, there were three primary issues with this approach:

First, we immediately noticed that while it didn’t seem horrible overall, we could see the contrast was off. While technically, all the colors in reverse met WCAG standards (AA+ on all text used on the primary dark mode background color, gray-900), there was not enough separation of the upper end of the grayscale to give a meaningful visual hierarchy to the text.

Fern's grayscale color palette and an example of it reversed

Light mode grayscale on top; reversed grayscale on the bottom

Side-by-side comparison of the reversed grayscale in dark mode

Side-by-side comparison of the reversed grayscale in dark mode

The other issue was that it would’ve taken a lot of mental overhead to constantly do this reverse mapping on the fly when we’re building new components or changing existing ones. Sure, we could’ve defined this reverse mapping in our Tailwind config, but we quickly abandoned that idea due to the underlying contrast issues.

And lastly, we also didn’t quite like the look of our grayscale in dark mode — it has a slight blue-ish tint to it, and while this works great in light mode, it made the app look cold and lifeless in dark mode. Subjective, of course, and some people might prefer that, but we didn’t.

Fern in reversed grayscale dark mode

Dark mode with reversed grayscale

There were similar issues with overall “look and feel” and contrast when it came to the other primary color palettes in our design system. We obviously couldn’t leave the non-gray colors as is because they would be too bright compared to everything else. However, simply reversing them also yielded non-desirable results.

Badges in various color variations

Badges in light mode (left); badges in dark mode (center); badges with reverse colors in dark mode (right)

Bespoke colors for dark mode

After hitting dead ends in all of our explorations in making our existing color palette work in dark mode, we realized we had to create a custom color palette exclusively for dark mode.

The things that were important to us when we started designing that custom new palette:

  • Grayscale should look and feel warmer than our light mode one
  • Non-gray primary colors should feel at home in dark mode
  • All colors should retain a similar level of contrast

We started with the grayscale because that will dictate all the other colors in the design system. Here I used another tool, a cheat code, to help me get started with the palette. Supa Palette is a helpful little Figma plugin that allows you to generate color palettes with reasonable control over the details. It is a paid plugin but comes with a free trial.

That being said, it’s not perfect, and we needed to do a fair amount of manual tweaking to get it to look exactly how we wanted it. We primarily had to play with the saturation and luminance of each color to bring them into harmony with each other and to have similar contrast with the light mode.

A newly designed grayscale for dark mode

We implemented this in the code by adding a new scale variable, night, to our Tailwind config. We also added two extra steps to this scale: 0 is the equivalent of white, and 1000 is the equivalent of black in dark mode. This seemed more sensible than using night-white and night-black, but I digress.

gray: {
  25: "#FCFCFD",
  50: "#F9FAFB",
  100: "#F2F4F7",
  200: "#EAECF0",
  300: "#D0D5DD",
  400: "#98A2B3",
  500: "#667085",
  600: "#475467",
  700: "#344054",
  800: "#1D2939",
  900: "#101828",
},
night: {
  0: "#0D0D0C",
  25: "#121211",
  50: "#171816",
  100: "#1F201D",
  200: "#292A27",
  300: "#41423D",
  400: "#60625B",
  500: "#81847B",
  600: "#A2A59D",
  700: "#BEBFBA",
  800: "#DCDDDA",
  900: "#F0F0EF",
  1000: "#FFFFFF",
},

Regarding the other three primary colors in our design system, the process was very similar: Supa Palette for generating a reverse scale (keeping the hue from the colors in light mode), then manual adjustment of saturation and luminance.

A newly designed primary color scale for dark mode

I don’t have many helpful tips for this process other than doing a lot of manual trial and error until you settle on something that works. You might not be as particular about the colors as we were, so Supa Palette might actually get you all the way there. YMMV!

The result

With all that work out of the way, we can finally see what our dark mode looks like. As mentioned above, it took some trial and error, but we’re pretty happy with how it turned out.

Fern in dark mode

Most importantly, this makes it super easy for us to integrate new components into dark mode, because the color mapping between the two color schemes is identical. Let’s use the over-simplified first example from above to demonstrate what I mean:

<div class="bg-white dark:bg-night-0">
	<p class="text-gray-900 dark:text-night-900">Look at this fine text!</p>
</div>

Example of text in light and dark mode

And here’s a side-by-side comparison of the existing grayscale reversed for dark mode and a grayscale explicitly designed for the purpose. Again, we think the difference is like night and day…

Fern UI compared with new dark mode and reversed grayscale dark mode

Closing thoughts

Our dark mode is currently only triggered by system-level settings, but we respect user choice and will eventually make this configurable.

There is also room for improvement in our dark mode primary color scales, and we’ll keep tweaking it slightly until it feels just right. Our red (error) scale is a little too poppy  in dark mode, but it’s not something the user frequently sees in the interface, so that’s fine for now.

Overall, we don’t think adapting your design system for dark mode is overly complicated, and by leveraging clever tooling, you can make the process much simpler. You now also know some pitfalls to avoid when you start this undertaking.

Useful resources

Supa Palette is something we already mentioned above, and this is a convenient tool for working with and designing color scales. It is paid but comes with a free trial to test it out.

Contrast is a contrast checker plugin for Figma, which we relied heavily on to ensure our colors have enough contrast and are WCAG compliant.

Tailwind has made “translating” our designs to dark mode an absolute breeze, and if you’re not already using it, you absolutely should!

Apple has an excellent little overview of how they recommend approaching dark mode, especially in their ecosystem.

Ready to launch your next research study in minutes instead of hours or days?

Get to insights faster and build products your users will love.
Get early access