Implementing Tailwind CSS Dark Mode Toggle with No Flicker

Implementing Tailwind CSS Dark Mode Toggle with No Flicker

·

9 min read

Originally published at cruip.com

Dark layouts have become increasingly popular in interface designs, and we at Cruip are proud to have embraced this trend from the beginning. We’ve developed several best-seller Tailwind CSS templates in dark skin, and in this tutorial, we will show you how to add a dark functionality to your layout bypassing one of the most common mistakes developers make: The flickering effect.

The flickering effect occurs when a user sets their preference to dark mode and navigates the site. The page initially loads in light mode but quickly switches to dark mode. This annoyance can be avoided through appropriate implementation.

Let’s see how we can add a dark layout toggle to our interfaces using HTML, React, and Vue.

Dark Mode implementation with HTML and JS only

In this first part, I’ll show you how to integrate a Tailwind CSS dark mode toggle into a static website using only HTML and JavaScript.

Here are the steps we’ll follow:

  • Enable Dark Mode in the Tailwind CSS configuration file

  • Create an accessible toggle button using a checkbox

  • Determine the default theme based on the user’s operating system preferences (prefers-color-scheme)

  • Save the user’s preference in localStorage to remember their choice during navigation or subsequent visits

  • Prevent the annoying flickering effect during page loading, also known as FOUC (flash of unstyled content)

Let’s get started!

Enabling Dark Mode in Tailwind CSS

If you’re familiar with Tailwind CSS, you might know that the framework allows enabling dark mode by simply adding darkMode: 'class' to the Tailwind configuration file.

  /** @type {import('tailwindcss').Config} */
  module.exports = {
    darkMode: 'class',
    // ...
  }

Once enabled, you can use the dark class on the html tag (or any other element) to apply the dark mode styles to all children.

Whenever you use the dark class, Tailwind magically applies styles prefixed with dark: to override default styles.

Creating a Dark Mode toggle switch

Now, let’s add a button that lets users switch between Light and Dark Mode. For this, we’re going with a checkbox input because it’s the most accessible and straightforward solution.

  <input type="checkbox" name="light-switch" class="light-switch" />
  <label for="light-switch">Switch to light / dark version</label>

By default, the checkbox is disabled (representing the light theme). To make it look like a toggle button, we’ll use some Tailwind CSS classes:

  <div class="flex flex-col justify-center ml-3">
      <input type="checkbox" name="light-switch" class="light-switch sr-only" />
      <label class="relative cursor-pointer p-2" for="light-switch">
          <svg class="dark:hidden" width="16" height="16" xmlns="http://www.w3.org/2000/svg">
              <path class="fill-slate-300" d="M7 0h2v2H7zM12.88 1.637l1.414 1.415-1.415 1.413-1.413-1.414zM14 7h2v2h-2zM12.95 14.433l-1.414-1.413 1.413-1.415 1.415 1.414zM7 14h2v2H7zM2.98 14.364l-1.413-1.415 1.414-1.414 1.414 1.415zM0 7h2v2H0zM3.05 1.706 4.463 3.12 3.05 4.535 1.636 3.12z" />
              <path class="fill-slate-400" d="M8 4C5.8 4 4 5.8 4 8s1.8 4 4 4 4-1.8 4-4-1.8-4-4-4Z" />
          </svg>
          <svg class="hidden dark:block" width="16" height="16" xmlns="http://www.w3.org/2000/svg">
              <path class="fill-slate-400" d="M6.2 1C3.2 1.8 1 4.6 1 7.9 1 11.8 4.2 15 8.1 15c3.3 0 6-2.2 6.9-5.2C9.7 11.2 4.8 6.3 6.2 1Z" />
              <path class="fill-slate-500" d="M12.5 5a.625.625 0 0 1-.625-.625 1.252 1.252 0 0 0-1.25-1.25.625.625 0 1 1 0-1.25 1.252 1.252 0 0 0 1.25-1.25.625.625 0 1 1 1.25 0c.001.69.56 1.249 1.25 1.25a.625.625 0 1 1 0 1.25c-.69.001-1.249.56-1.25 1.25A.625.625 0 0 1 12.5 5Z" />
          </svg>
          <span class="sr-only">Switch to light / dark version</span>
      </label>
  </div>

The toggle switch will look like this:

The dark mode toggle button after adding Tailwind CSS classes and some SVGs

Next up, let’s handle the JavaScript side of things. You can either create a separate .js file or add the code directly to your HTML just before the closing body tag.

  <script>
  const lightSwitches = document.querySelectorAll('.light-switch');
  if (lightSwitches.length > 0) {
    lightSwitches.forEach((lightSwitch, i) => {
      if (localStorage.getItem('dark-mode') === 'true') {
        lightSwitch.checked = true;
      }
      lightSwitch.addEventListener('change', () => {
        const { checked } = lightSwitch;
        lightSwitches.forEach((el, n) => {
          if (n !== i) {
            el.checked = checked;
          }
        });
        if (lightSwitch.checked) {
          document.documentElement.classList.add('dark');
          localStorage.setItem('dark-mode', true);
        } else {
          document.documentElement.classList.remove('dark');
          localStorage.setItem('dark-mode', false);
        }
      });
    });
  }
  </script>

Let’s break down the above code to see how it works:

  • const lightSwitches = document.querySelectorAll('.light-switch'); gets all elements with the class light-switch and stores them in a variable

  • if (localStorage.getItem('dark-mode') === 'true') { lightSwitch.checked = true; } checks if the user has previously opted for dark mode. If so, we make sure the checkbox input reflects that by setting it as checked

  • lightSwitch.addEventListener('change', () => { ... }); adds an event listener to the checkbox input that triggers every time the user changes its state

  • const { checked } = lightSwitch; stores the checkbox input’s value in the checked variable

  • lightSwitches.forEach((el, n) => { if (n !== i) { el.checked = checked; } }); ensures that all checkbox inputs synchronize with the input that triggered the event, maintaining a consistent state

  • The final code block adds or removes the dark class from the html element, based on the value of the checkbox input that triggered the event, and saves the user’s preference in localStorage

Serving the right theme on page load

Here comes the crucial part: determining the theme to display when the page first loads. We’ve got a few scenarios to account for:

  • If it’s the user’s first visit, we’ll stick to the default light theme

  • If it’s their first visit, but dark mode is their system preference, we’ll serve the dark theme

  • For returning visitors who’ve toggled before, we’ll keep things consistent by displaying the theme they previously chose, saved in localStorage

To make this happen, we’ll add some more JavaScript. This time, we’ll place it within the head tag of our HTML file. By doing this, our JavaScript will run before the page loads, allowing us to avoid the unwanted flickering effect.

  <script>
  if (localStorage.getItem('dark-mode') === 'true' || (!('dark-mode' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
      document.querySelector('html').classList.add('dark');
  } else {
      document.querySelector('html').classList.remove('dark');
  }
  </script>

With this step, we’re all set. In the upcoming sections, I’ll show you how to integrate the Tailwind CSS dark mode toggle into dynamic sites using Next.js and Vue.

Dark Mode implementation with Next.js

JavaScript frameworks like Next.js and Vue require different strategies for the dark mode implementation. Our earlier solution won’t work.

Let’s start with Next.js. We’ll use the next-themes package, which allows us to handle dark mode very easily without worrying about blocking page rendering to check user preferences.

Of course, we must wnsure to enable dark mode in Tailwind CSS, as we saw earlier. After that, we’ll install next-themes with the terminal command npm i next-themes --save.

Once that’s done, we need a component that lets users toggle dark mode on and off. For this, create a new file named theme-toggle.tsx and inject the following code with the checkbox input we created earlier:

  'use client'

  import { useTheme } from 'next-themes'

  export default function ThemeToggle() {

    const { theme, setTheme } = useTheme()

    return (
      <div className="flex flex-col justify-center ml-3">
        <input
          type="checkbox"
          name="light-switch"
          className="light-switch sr-only"
          checked={theme === 'light'}
          onChange={() => {
            if (theme === 'dark') {
              return setTheme('light')
            }
            return setTheme('dark')
          }}
        />
        <label className="relative cursor-pointer p-2" htmlFor="light-switch">
          <svg className="dark:hidden" width="16" height="16" xmlns="http://www.w3.org/2000/svg">
            <path
              className="fill-slate-300"
              d="M7 0h2v2H7zM12.88 1.637l1.414 1.415-1.415 1.413-1.413-1.414zM14 7h2v2h-2zM12.95 14.433l-1.414-1.413 1.413-1.415 1.415 1.414zM7 14h2v2H7zM2.98 14.364l-1.413-1.415 1.414-1.414 1.414 1.415zM0 7h2v2H0zM3.05 1.706 4.463 3.12 3.05 4.535 1.636 3.12z"
            />
            <path className="fill-slate-400" d="M8 4C5.8 4 4 5.8 4 8s1.8 4 4 4 4-1.8 4-4-1.8-4-4-4Z" />
          </svg>
          <svg className="hidden dark:block" width="16" height="16" xmlns="http://www.w3.org/2000/svg">
            <path className="fill-slate-400" d="M6.2 1C3.2 1.8 1 4.6 1 7.9 1 11.8 4.2 15 8.1 15c3.3 0 6-2.2 6.9-5.2C9.7 11.2 4.8 6.3 6.2 1Z" />
            <path
              className="fill-slate-500"
              d="M12.5 5a.625.625 0 0 1-.625-.625 1.252 1.252 0 0 0-1.25-1.25.625.625 0 1 1 0-1.25 1.252 1.252 0 0 0 1.25-1.25.625.625 0 1 1 1.25 0c.001.69.56 1.249 1.25 1.25a.625.625 0 1 1 0 1.25c-.69.001-1.249.56-1.25 1.25A.625.625 0 0 1 12.5 5Z"
            />
          </svg>
          <span className="sr-only">Switch to light / dark version</span>
        </label>
      </div>
    )
  }

There’s not much to explain, really. We’ve simply imported useTheme from next-themes and used the hook to get the current theme and set the theme. Easy, right?

Now, however, we need to add and remove the dark class from the html element based on the current theme. To do this, create a new file named theme-provider.tsx and add this code:

  'use client'

  import { ThemeProvider } from 'next-themes'

  export default function Theme({ children }: { children: React.ReactNode }) {
    return (
      <ThemeProvider attribute="class">
        {children}
      </ThemeProvider>
    )
  }

We’re not quite done yet. Since we’re in the Next.js 13 era with the app directory, let’s get into the layout.tsx file and include the theme provider we created earlier:

  import Theme from './theme-provider'

  export default function Layout({ children }) {
    return (
      <html suppressHydrationWarning>
        <body>
          <Theme>{children}</Theme>
        </body>
      </html>
    )
  }

Note that we added suppressHydrationWarning to the html tag to prevent Next.js from showing a warning in the console.

Dark Mode implementation with Vue

Now, let’s talk about Vue! Once again, we’ll use an external library to handle dark mode simply and quickly. In this case, the library is called VueUse, and you can install it using the command npm i @vueuse/core --save.

After that, create a new file named ThemeToggle.vue, and add this code:

  <template>
    <div>
      <input type="checkbox" name="light-switch" v-model="isDark" class="light-switch sr-only" />
      <label class="flex items-center justify-center cursor-pointer w-8 h-8 bg-slate-100 hover:bg-slate-200 dark:bg-slate-700 dark:hover:bg-slate-600/80 rounded-full" for="light-switch">
        <svg class="w-4 h-4 dark:hidden" width="16" height="16" xmlns="http://www.w3.org/2000/svg">
          <path class="fill-current text-slate-400" d="M7 0h2v2H7V0Zm5.88 1.637 1.414 1.415-1.415 1.413-1.414-1.414 1.415-1.414ZM14 7h2v2h-2V7Zm-1.05 7.433-1.415-1.414 1.414-1.414 1.415 1.413-1.414 1.415ZM7 14h2v2H7v-2Zm-4.02.363L1.566 12.95l1.415-1.414 1.414 1.415-1.415 1.413ZM0 7h2v2H0V7Zm3.05-5.293L4.465 3.12 3.05 4.535 1.636 3.121 3.05 1.707Z" />
          <path class="fill-current text-slate-500" d="M8 4C5.8 4 4 5.8 4 8s1.8 4 4 4 4-1.8 4-4-1.8-4-4-4Z" />
        </svg>
        <svg class="w-4 h-4 hidden dark:block" width="16" height="16" xmlns="http://www.w3.org/2000/svg">
          <path class="fill-current text-slate-400" d="M6.2 2C3.2 2.8 1 5.6 1 8.9 1 12.8 4.2 16 8.1 16c3.3 0 6-2.2 6.9-5.2C9.7 12.2 4.8 7.3 6.2 2Z" />
          <path class="fill-current text-slate-500" d="M12.5 6a.625.625 0 0 1-.625-.625 1.252 1.252 0 0 0-1.25-1.25.625.625 0 1 1 0-1.25 1.252 1.252 0 0 0 1.25-1.25.625.625 0 1 1 1.25 0c.001.69.56 1.249 1.25 1.25a.625.625 0 1 1 0 1.25c-.69.001-1.249.56-1.25 1.25A.625.625 0 0 1 12.5 6Z" />
        </svg>
        <span class="sr-only">Switch to light / dark version</span>
      </label>
    </div>
  </template>

  <script setup>
  import { useDark } from "@vueuse/core";
  const isDark = useDark({
    selector: 'html',
  })
  </script>

Notice how we used useDark from VueUse to get the current theme and set the theme. We also used v-model to bind the checkbox input value to the current theme.

Unlike Next.js, in Vue, we don’t need a provider to handle dark mode! We can simply use useDark in any component, and it will work. Simple as pie, right?

Conclusions

If you want to see how we at Cruip have previously implemented this technique, take a look at one of our templates below:

There are a lot of them, but as we’d told you before, we’re big fans of dark layouts 🙂