How I implemented TailwindCSS dark mode in my Nuxt blog

How I implemented TailwindCSS dark mode in my Nuxt blog

Here's how I added dark mode theme functionality that respects user preference using the `prefers-color-scheme` and localStorage and a cool "click" sound effect.

  • Nuxt 2
  • Vue
  • TailwindCSS
  • Dark mode
Miracle Onyenma
Miracle Onyenma
11 min read

I've always been in love with this dark mode functionality of a thing. So when I learnt that TailwindCSS - A Utility-first CSS Framework supports dark mode out of the box, I was excited. That's probably one of the top reasons I fell in love with Tailwind.

I've been working on a redesign of my portfolio and blog in my free time and I just had to add this feature to my site, it just wouldn't be complete without it.

Although it's a well known fact that dark mode offers a ton of benefits to site visitors, like it:

  • helps their device consume less battery on OLED screens,
  • keeps them from squeezing their eyelids and trying to block the excess light while browsing in dark places by just turning on dark mode
  • also prevents them from cranking their brightness 🔆 all the way up because they are trying to read what's on the page while outdoors
  • is just plain cool 😎

My new site runs on Nuxt which is basically a server side rendering framework like Next.js (which is for React) but for Vue. I'm not going to cover how I set up the site with Nuxt Content and Tailwind in this article but here's a link to that here.

Approaching The Problem

If you look at the Tailwind docs on dark mode, there's an example of a common approach for dark mode and respecting user preference and that approach is what I was looking for.

What I want for my site is that:

  • The system theme preference is respected by first time users - this is where prefers-color-scheme comes in.
  • This preference is now cached in localStorageand upon the user's next visit, the cached theme will be used
  • The user can also toggle theme mode and that will be saved to localStorage for their next visit

Now, the thing with Tailwind's example is that it's very basic and you have to figure out how to implement it.

Prerequisites

Before we jump into how I transformed that basic example into a feature that works, there's a few things I did beforehand.

Enable Manual Dark Mode

According to Tailwind's docs

If you want to support toggling dark mode manually instead of relying on the operating system preference, use the class strategy instead of the media strategy

It's important to note that we'll have to add the .darkclass to our HTML document (so that it'll be applied throughout the app ) in order to apply any dark:{class} class to elements. That's why we need to be able to control this class manually.

To do this, just change media to class in the darkMode property in tailwind.config.js file

// tailwind.config.js
module.exports = {
  darkMode: 'class',
  // ...
}

Now, when dark mode is enabled, <html> should have a class of dark. Then, all dark:{class} classes will be applied

<!-- Dark mode enabled -->
<html class="dark">
  <body>
    <!-- dark:bg-black Will be applied -->
    <div class="bg-white dark:bg-black">
      <!-- ... -->
    </div>
  </body>
</html>

🆒

Enable Dark Mode Variant For Other Utilities

By default, dark mode is only enabled for color-related utilities/classes, so something like this will work:

<p class="text-black dark:text-white">Some text</p>

but this will not work:

<p class="transform translate-x-1 dark:translate-x-2">Some text</p>

All you have to do is enable dark mode on our desired utilities:

// tailwind.config.js
module.exports = {
  //...
  variants: {
    extend: {
      translate: ['dark'],
      typography: ['dark'], //if you've setup Tailwind Typography Plugin for dark mode
    },
  },
}

You'll notice, I added 'dark' to the variant list for typography, that's for the Tailwind Typography plugin I'm using. This is beyond the scope of this article, but I'll share how I implemented dark mode for that plugin soon. Now that we've enabled the dark mode variant for our utilities, let's proceed.

Implementing The Feature

In order to do this, I had to create a components/themeModule folder containing some functions in the themeModule/initTheme.js and my toggle button component in themeModule/themeControl.vue. So my file structure basically looks like this:

|
+-- components
| +-- themeModule
| +-- initTheme.js
| +-- themeControl.vue
|

What I'm trying to do can also very easily be implemented with a state management pattern like Vuex which happens to come with Nuxt by default. But, I just decided not to use it. But, if you're interested to see how you can implement this feature using Vuex, you can check out this great article by @Tony Ketcham where he used it.

Set Theme On User Visit

In themeModule/initTheme.js I have the following piece of code:

// components/themeModule/initTheme.js

const initTheme = () => {
  const savedTheme = localStorage.theme
  const userPrefersDark = window.matchMedia(
    '(prefers-color-scheme: dark)'
  ).matches
  const newTheme =
    {}(!savedTheme && userPrefersDark) ||
    (savedTheme && JSON.parse(savedTheme).mode === 'dark')
      ? (newTheme.mode = 'dark')
      : (newTheme.mode = 'light')

  localStorage.theme = JSON.stringify(newTheme)

  return { newTheme }
}

What's happening here?

  • First, we'll see if the "theme" is present in localStorage already, and if a value is present, it'll be assigned to savedTheme, if none, savedTheme will be undefined
  • Then, we get the preferred color scheme of the system. If it's dark, userPrefersDark will be true, else it'll be false
  • Assigning an empty object to newTheme, my desired structure for this object is this:
        theme: {
          mode: "dark" || "light",
          //... some other property
        }
    

    This is because I intend to add more options like main color to the theming functionality.

I have a compound ternary operator that does basically two things:

  1.  !savedTheme && userPrefersDark
    
    Check if no theme has been set in localStorage and if the system the is set to "dark". If this is true, it means that this is the first time the user has visited the page (hence, no cached theme in localStorage yet) and that the system preference has been set to dark.
  2.  savedTheme && JSON.parse(savedTheme).mode === 'dark'
    
    Check if theme was saved to localStorage and theme.mode was equal to "dark" If this is true on the other hand, it means that this isn't the first user visit and that a mode has saved to localStorage, so we should use that.

These two conditions imply that the desired mode is dark. If either of them is true, the mode should be set to dark:

? (newTheme.mode = 'dark')

If both of the two compound conditions are false, the desired mode is dark:

: (newTheme.mode = 'light')

I hope this all makes sense. If you don't understand how ternary operators work, here's a link that will help: Make Your Code Cleaner with JavaScript Ternary Operator (javascripttutorial.net)

For now, here's the classic if else equivalent:

if (
  (!savedTheme && userPrefersDark) ||
  (savedTheme && JSON.parse(savedTheme).mode === 'dark')
) {
  newTheme.mode = 'dark'
} else {
  newTheme.mode = 'light'
}

Alright. now we can save the newTheme to localStorage and return it:

localStorage.theme = JSON.stringify(newTheme)

return { newTheme }```

That's it for the initTheme() function.

I prefer to run this function at the topmost level of my app which is layouts/default.vue.

So, in that file, I added the following:

<!-- layouts/default.vue -->
<!-- ... -->
<script>
  import { initTheme } from '../components/themeModule/initTheme'

  export default {
    data() {
      return {
        theme: {},
      }
    },

    watch: {
      theme: {
        deep: true,
        handler(data) {
          const { mode } = data
          const html = document.documentElement

          mode === 'dark'
            ? html.classList.add('dark')
            : html.classList.remove('dark')

          localStorage.theme = JSON.stringify(data)
        },
      },
    },
    methods: {
      // this function toggles the value of `theme.mode`
      toggle() {
        this.theme.mode === 'dark'
          ? (this.theme.mode = 'light')
          : (this.theme.mode = 'dark')
      },
    },

    beforeMount() {
      this.theme = initTheme().newTheme
      // listen to 'toggle-theme' event and run the `toggle()` method when fired
      this.$nuxt.$on('toggle-theme', this.toggle)
    },
  }
</script>

In <script>, I import initTheme function then I do the following:

  • Create a theme data property in data(),
  • Set the theme data to the value returned by initTheme in beforeMount() which runs when the app is visited.
Take note of this.$nuxt.$on('toggle-theme', this.toggle). View more
  • The toggle() method checks if the current theme mode is "dark", if it is, it sets it to "light". If it's not "dark", it set's it to dark. That's the basic toggle functionality right there.
  • Set up a watcher for theme data.
  • deep: true ensures that the value properties of the object are watched as well.
  • We have another ternary operator which checks to see if the new mode is "dark", if it is, its adds the .dark class to html document. If the mode is not "dark", it removes the .dark class.
  • Then, it saves the new data to localStorage

Right now, the app basically checks localStorage and system preference to determine whether or not to enable dark mode. What's left is the toggle button/component.

Adding Toggle Functionality

What's left now is to create a button component that users will click to toggle between light and dark mode. Behold, themeModule/themeControl.vue

<!-- components/themeModule/themeControl.vue -->
<!-- ... -->

<template>
  <button class="toggler" @click="toggle()">
    <div class="wrapper">
      <!-- custom icon components -->
      <feather-icon name="sun" />
      <feather-icon name="moon" />
    </div>
  </button>
</template>

<script>
  export default {
    data() {
      return {
        clickSound: '',
      }
    },

    methods: {
      toggleFunc() {
        // nuxt global event emitter
        this.$nuxt.$emit('toggle-theme')
        // play cool sound effect
        this.clickSound.play()
      },
    },

    beforeMount() {
      // get audio file from static folder, create new Audio function and assign it to `clickSound` data ob
      this.clickSound = new Audio('/audio/mixkit-classic-click-1117.mp3')
    },
  }
</script>

<style scoped>
    .toggler {
      @apply relative w-6 h-6 ml-6 overflow-hidden;
    }
    .toggler .wrapper {
      @apply inline-flex transition transform;
      /* dark variant for button */
      @apply dark:-translate-x-1/2;
    }
</style>

Basically, when clicked, in the toggleFunc() method, a global custom event toggle-theme is emitted. If you recall, we have a global listener in layouts/default.vue. This listener is in layouts/default.vue, is responsible for changing the theme in the app as we have seen in the previous section.

The Sound Effect

For the sound effect, I just downloaded a free audio file I found on mixkit.co, compressed the file using www.onlineconverter.com, downloaded it again and added it to my /static/audio folder. This allows me to create a new Audio element from a file without webpack like so: new Audio('/audio/mixkit-classic-click-1117.mp3'), assign it to this.clickSound . Then, play it in toggleFunc() like so: this.clickSound.play(). It's pretty simple.

Here's my toggler in action:

GIF of the toggler on my blog
GIF of the toggler on my blog

The Toggler Animation

I created the transition by simply adding the dark:-translate-x-1/2 variant to .wrapper. That's why I had to enable the dark mode variant for the the translate utility in the beginning for this to work. The icons are styled side-by-side in a wrapper. The container which is the .togglerbutton has overflow set to hidden. So, when the dark class is active, the .wrapper translates on the x-axis by 50%, revealing the moon while hiding the star and vice-versa.

Conclusion

Well, I've tried to give a detailed rundown of how I implemented this dark mode thing on my Nuxt site. Without Vuex 😋. If you have any thoughts, questions, or recommendations, please feel free to reach out. I'd really appreciate feedback.

Further Reading

Here are some resources I think you might find useful:

Appreciate and share 🙌🏾 if you feel this could be useful to someone out there ✨ Thanks for reading ❤.