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 adarkclass 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:rootandhtml.dark. - Watch for third-party components — UI libraries may not respect your
darkclass. 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.