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.