loading...
Cover image for Vue + Tailwind 2.0: dark mode using Vuex, localStorage, and user's default preference

Vue + Tailwind 2.0: dark mode using Vuex, localStorage, and user's default preference

tonyketcham profile image Tony Ketcham (he/they) ・4 min read

TailwindCSS 2.0 is sick. Native dark mode, tons of colors, tons of other shit. I just so happened to start a new Gridsome project for documenting my recently developed tea dependency right when Tailwind 2.0 dropped, and I wanted to toss in some theme toggling to represent the dark times ahead.

The kind of conditional theming I want is a little complex:

  • first-time visitors should be displayed their OS/browser preferred theme
  • the user's theme choice should be respected throughout their session
  • the user's theme choice should be saved in local storage so that they don't have to battle the UI when they return

While Tailwind has an option to pop theme selection into manual mode, the example in the docs for driving stick is vague and purposely non-specific. CTRL+C CTRL+V won't do a ton for us here, so we're on our own in VS Code.

Let's implement our own stateful dark mode solution using Vuex and localStorage.

The deep state

After dicking around trying to add Vuex into an existing Gridsome project for 45 minutes while the official Gridsome docs decided to take me out back to the console.error firing squad, I eventually got to the point of being the proud father to my very own Vuex store:

import Vue from 'vue'
import Vuex from 'vuex'
import theme from './modules/theme'

Vue.use(Vuex)

export default new Vuex.Store({
    state: {},
    mutations: {},
    actions: {},
    modules: {
        theme
    },
})
Enter fullscreen mode Exit fullscreen mode

Now inside that theme.js module, we can do some simple tricks to tuck most of our logic away from the frontend. Let's start with the state and mutation:

export default {
    state: {
        theme: {}
    },
    mutations: {
        SET_THEME(state, theme) {
            state.theme = theme;
            localStorage.theme = theme;
        }
    },
...
Enter fullscreen mode Exit fullscreen mode

We're keeping the mutation incredibly simple so that its sole responsibility is updating the theme's state. Saving that to localStorage allows us to retrieve the user's most recently selected theme after closing the page and coming back later, essentially like a cookie.

Now think about what happens when a first-time user pops on our site. There's a chance they've selected either the light or dark theme on their OS or browser, which we can and should respect. Notice that we haven't initialized the theme state with anything yet. That's going to be our first action:

...
 actions: {
        initTheme({ commit }) {

            const cachedTheme = localStorage.theme ? localStorage.theme : false;
            //  `true` if the user has set theme to `dark` on browser/OS
            const userPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

            if (cachedTheme)
                commit('SET_THEME', cachedTheme)
            else if (userPrefersDark)
                commit('SET_THEME', 'dark')
            else
                commit('SET_THEME', 'light')

        },
...
Enter fullscreen mode Exit fullscreen mode

This action will then see if the user has been to the site before:

  • If so, we use their cached theme preference.
  • If the person is new, we check to see if their system is set to dark mode. If so, we commit our store & cache to dark mode.
  • Else, we default to light mode.

We can dispatch this action from an omnipresent root of the app that everyone will hit no matter their point of entry:

// for me this is `layouts/Default.vue`, but for you it may be `App.vue` or something else
export default {
  beforeMount() {
    this.$store.dispatch("initTheme");
  },
...
Enter fullscreen mode Exit fullscreen mode

Now we need a switch to toggle the theme.

Before making that component, let's go back to theme.js to add in the logic:

...
// This simply flips whatever was most recently committed to storage.
    toggleTheme({ commit }) {

            switch (localStorage.theme) {
                case 'light':
                    commit('SET_THEME', 'dark')
                    break;

                default:
                    commit('SET_THEME', 'light')
                    break;
            }
        }
    },
    getters: {
        getTheme: (state) => {
            return state.theme;
        }
    },
}
Enter fullscreen mode Exit fullscreen mode

Tailwind 2.0's dark class

From here, we can add in some properties to conditionally render on dark mode. Then we'll set up a watcher that will react to changes to the theme selection by running a function that adds and removes Tailwind's dark css class to the root node of our app:

<template>
  <main
    class="min-h-screen 
           bg-green-50 text-gray-700 
           dark:bg-gray-900 dark:text-purple-50">
    <ThemeToggler/>
    <slot />
  </main>
</template>

<script>
import { mapGetters } from "vuex";
import ThemeToggler from "../components/ThemeToggler.vue";

export default {
  components: {
    ThemeToggler,
  },
  beforeMount() {
    this.$store.dispatch("initTheme");
  },
  computed: {
    ...mapGetters({ theme: "getTheme" }),
  },
  watch: {
    theme(newTheme, oldTheme) {
      newTheme === "light"
        ? document.querySelector("html").classList.remove("dark")
        : document.querySelector("html").classList.add("dark");
    },
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

And the last piece, our incredibly underwhelming toggler:

<template>
    <button 
     @click="toggleTheme"
     class="dark:text-red-400 text-cyan-200">
      Theme Toggle
    </button>
</template>

<script>
export default {
  methods: {
    toggleTheme() {
      this.$store.dispatch("toggleTheme");
    },
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

Creative freedom

This is lovely because you can get wild, replace the text with some SVGs, and add transitions out the wazoo to translate between states. You can use Vue's built-in transition elements or Tailwind's transition classes, or both!

If you have any questions, suggestions, or more elegant solutions, please post them below! I spotted a couple of areas that could be more elegant, like using Vue's conditional classes instead of vanilla-ing the dark class on and off the root element.

Discussion

pic
Editor guide