Matthew Hodge
Full Stack Developer

Tailwind CSS Dark Mode: Class Strategy with Vue

Dark mode has gone from a nice-to-have to something users genuinely expect. Tailwind makes the styling side easy — you just prefix utilities with dark: — but wiring up the toggle, persisting the user's preference, and avoiding a flash of the wrong theme on load takes a bit of thought.

In this post, I'll cover Tailwind's class strategy for dark mode, build a persistent toggle with a Vue composable, and touch on setting up a sensible custom colour palette for both modes.


The Two Strategies

Tailwind offers two dark mode strategies:

  • media — automatically applies dark styles based on the OS preference (prefers-color-scheme). Simple, zero JavaScript.
  • class — applies dark styles when a dark class is present on a parent element (usually <html>). Lets users toggle manually.

For most apps where you want a toggle, class is the right call. Set it in tailwind.config.js:

// tailwind.config.js

export default {
    darkMode: 'class',
    content: [
        './components/**/*.{vue,js}',
        './pages/**/*.vue',
        './layouts/**/*.vue',
        './app.vue',
    ],
    theme: {
        extend: {},
    },
}

Now any dark: variant will activate when <html class="dark">.


Writing Dark Mode Classes

With the class strategy enabled, you write both modes side by side:

<div class="bg-white text-gray-900 dark:bg-gray-900 dark:text-gray-100">
    <p class="text-gray-600 dark:text-gray-400">
        Some body text here.
    </p>
    <button class="bg-indigo-600 text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-400">
        Click me
    </button>
</div>

The light styles are your defaults, the dark: prefixed ones kick in when html.dark is present.


A useDarkMode Composable

Rather than toggling the class in every component that has a theme button, pull it into a composable. This also handles reading and persisting the user's preference.

// composables/useDarkMode.js

import { ref, watch, onMounted } from 'vue'

const isDark = ref(false)

export function useDarkMode() {
    function applyTheme(dark) {
        if (dark) {
            document.documentElement.classList.add('dark')
        } else {
            document.documentElement.classList.remove('dark')
        }
    }

    function toggle() {
        isDark.value = ! isDark.value
    }

    watch(isDark, (newValue) => {
        applyTheme(newValue)
        localStorage.setItem('theme', newValue ? 'dark' : 'light')
    })

    onMounted(() => {
        const saved = localStorage.getItem('theme')
        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches

        // Saved preference wins, then fall back to OS preference
        isDark.value = saved ? saved === 'dark' : prefersDark
        applyTheme(isDark.value)
    })

    return { isDark, toggle }
}

Use it in a toggle component:

<!-- components/ThemeToggle.vue -->

<script setup>
import { useDarkMode } from '@/composables/useDarkMode'

const { isDark, toggle } = useDarkMode()
</script>

<template>
    <button
        @click="toggle"
        class="p-2 rounded-lg transition-colors
               text-gray-600 hover:text-gray-900 hover:bg-gray-100
               dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800"
        :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
    >
        <!-- Sun icon -->
        <svg v-if="isDark" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707M17.657 17.657l-.707-.707M6.343 6.343l-.707-.707M12 8a4 4 0 100 8 4 4 0 000-8z" />
        </svg>
        <!-- Moon icon -->
        <svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
                d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
        </svg>
    </button>
</template>

Drop <ThemeToggle /> anywhere in your layout and it works.


Avoiding the Flash on Load

The flash of the wrong theme (FODT) happens because the page renders before JavaScript loads and sets the class. The fix is an inline script in the <head> that runs synchronously — before anything else paints.

In a Nuxt project, add it via useHead in your app.vue or via a plugin:

<!-- app.vue -->

<script setup>
useHead({
    script: [
        {
            innerHTML: `
                (function() {
                    const saved = localStorage.getItem('theme');
                    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
                    if (saved === 'dark' || (!saved && prefersDark)) {
                        document.documentElement.classList.add('dark');
                    }
                })();
            `,
            type: 'text/javascript',
        },
    ],
})
</script>

Because this is inline and in the <head>, it runs before the browser paints the first frame. No flash.

For a standard Vue project with a plain HTML file:

<!DOCTYPE html>
<html lang="en">
<head>
    <script>
        (function() {
            const saved = localStorage.getItem('theme');
            const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
            if (saved === 'dark' || (!saved && prefersDark)) {
                document.documentElement.classList.add('dark');
            }
        })();
    </script>
    <!-- rest of head -->
</head>

A Custom Colour Palette

Rather than sprinkling bg-white dark:bg-gray-900 everywhere, define semantic colour tokens in your Tailwind config that you use throughout your components. This way, changing your dark mode background means one edit, not a find-and-replace across the whole codebase.

// tailwind.config.js

export default {
    darkMode: 'class',
    theme: {
        extend: {
            colors: {
                surface: {
                    DEFAULT: '#ffffff',
                    dark:    '#111827',
                },
                'surface-raised': {
                    DEFAULT: '#f9fafb',
                    dark:    '#1f2937',
                },
                content: {
                    DEFAULT: '#111827',
                    dark:    '#f9fafb',
                },
                muted: {
                    DEFAULT: '#6b7280',
                    dark:    '#9ca3af',
                },
            },
        },
    },
}

Then your markup becomes:

<div class="bg-surface dark:bg-surface-dark text-content dark:text-content-dark">
    <p class="text-muted dark:text-muted-dark">Secondary text</p>
</div>

Still verbose, but now each token has a clear purpose and you can retheme by changing one place.


Tips

  • Test both modes from the start — it's much harder to retrofit dark mode on a component that was designed light-only.
  • Check contrast in both modes. Tools like the Tailwind colour palette or browser accessibility tools will flag low-contrast text.
  • Use CSS variables if your palette gets complex — Tailwind works well with var(--color-surface) style tokens defined in :root and html.dark.
  • Watch for third-party components — UI libraries may not respect your dark class. Check their dark mode docs before committing.

Final Thoughts

The class strategy is a bit more work than media-only dark mode, but the extra control is worth it. Users expect to be able to set their preference independently of the OS, and they definitely expect it to be remembered.

Once you've got the composable and the anti-flash script in place, the rest is just writing dark: variants alongside your normal classes — and that part is genuinely painless with Tailwind.


Let's build a simple dashboard shell using Tailwind CSS's command-line approach. This method is perfect for quick prototyping and smaller projects where you don't need a full build system.

Step 1: Set Up Your Project

First, create a new project folder and initialize it:

mkdir tailwind-dashboard
cd tailwind-dashboard
npm init -y

Step 2: Install Tailwind CSS

Install Tailwind CSS as a development dependency:

npm install tailwindcss @tailwindcss/cli

Step 3: Create CSS Input File

Create a source CSS file that includes Tailwind:

// filepath: input.css
@import "tailwindcss";

Step 4: Create HTML File

Create an index.html file with our dashboard structure:

// filepath: index.html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Tailwind Dashboard</title>
  <link rel="stylesheet" href="./output.css">
</head>

<body class="bg-gray-100">
  <div class="min-h-screen flex flex-col">
    <header class="bg-header shadow-md">
      <nav class="bg-gray-800">
        <div class="mx-auto max-w-7xl px-2 sm:px-6 lg:px-8">
          <div class="relative flex h-16 items-center justify-between">

            <div class="flex flex-1 items-center justify-center sm:items-stretch sm:justify-start">
              <div class="flex shrink-0 items-center">
                <img class="h-8 w-auto" src="https://picsum.photos/100" alt="Your Company">
              </div>
              <div class="hidden sm:ml-6 sm:block">
                <div class="flex space-x-4">
                  <a href="#" class="rounded-md bg-gray-900 px-3 py-2 text-sm font-medium text-white"
                    aria-current="page">FIRST</a>
                  <a href="#"
                    class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white">SECOND</a>
                  <a href="#"
                    class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white">THIRD</a>
                  <a href="#"
                    class="rounded-md px-3 py-2 text-sm font-medium text-gray-300 hover:bg-gray-700 hover:text-white">FOURTH</a>
                </div>
              </div>
            </div>
            <div class="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
              <button type="button"
                class="relative rounded-full bg-gray-800 p-1 text-gray-400 hover:text-white focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800 focus:outline-hidden">
                <svg class="size-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
                  <path stroke-linecap="round" stroke-linejoin="round"
                    d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0" />
                </svg>
              </button>


              <div class="relative ml-3">
                <div>
                  <button type="button"
                    class="relative flex rounded-full bg-gray-800 text-sm focus:ring-2 focus:ring-white focus:ring-offset-2 focus:ring-offset-gray-800 focus:outline-hidden"
                    id="user-menu-button" aria-expanded="false" aria-haspopup="true">
                    <img class="size-8 rounded-full" src="https://picsum.photos/256/256" alt="">
                  </button>
                </div>
              </div>
            </div>
          </div>
        </div>

        <div class="sm:hidden" id="mobile-menu">
          <div class="space-y-1 px-2 pt-2 pb-3">
            <a href="#" class="block rounded-md bg-gray-900 px-3 py-2 text-base font-medium text-white"
              aria-current="page">FIRST</a>
            <a href="#"
              class="block rounded-md px-3 py-2 text-base font-medium text-gray-300 hover:bg-gray-700 hover:text-white">SECOND</a>
            <a href="#"
              class="block rounded-md px-3 py-2 text-base font-medium text-gray-300 hover:bg-gray-700 hover:text-white">THIRD</a>
            <a href="#"
              class="block rounded-md px-3 py-2 text-base font-medium text-gray-300 hover:bg-gray-700 hover:text-white">FOURTH</a>
          </div>
        </div>
      </nav>
    </header>

    <div class="flex flex-1 p-4">
      <aside id="sidebar" class="bg-sidebar w-64 fixed md:static">
        SIDEBAR
      </aside>

      <main class="flex-1">
        MAIN
      </main>
    </div>

    <footer class="bg-white py-4 px-6 shadow-inner">
      FOOTER
    </footer>
  </div>
</body>

</html>

Step 5: Build CSS

Now, let's use the Tailwind CLI to build our CSS file:

npx @tailwindcss/cli -i input.css -o output.css --watch

This command:

Takes our input.css with the Tailwind directives Processes it and outputs to output.css The --watch flag keeps the process running and rebuilds when files change

Step 6: View Your Dashboard

Open index.html in your browser to see your dashboard. As you modify your HTML, the CSS will automatically update thanks to the --watch flag.

Extending the Dashboard

Note that you have the basic shell, enhance it for your use case

Production Build

When you're ready for production, generate a minified CSS file:

npx tailwindcss -i input.css -o output.css --minify

This creates a smaller CSS file optimized for production.

Conclusion

You've successfully created a simple dashboard shell using Tailwind CSS's CLI approach. This method is perfect for prototyping and smaller projects where you don't need a complex build system.

The Tailwind CLI provides a streamlined workflow that makes it easy to create beautiful, responsive interfaces without the complexity of larger build systems.

For more information, check out the Tailwind CSS documentation.