Implementing Dark Mode in a Nuxt Application
Dark mode seems simple: invert the colours. In practice, there are several ways to do it wrong — flash of wrong theme on page load, system preference ignored, preference not persisted across sessions, SVGs and images that break the illusion.
Here's the correct implementation in Nuxt with Tailwind.
The approach: CSS custom properties + Tailwind's darkMode: 'class'
Tailwind's dark mode via the class strategy is the most flexible approach. You add a dark class to the <html> element to activate dark mode; Tailwind applies dark: prefixed utilities only when this class is present.
// tailwind.config.ts
export default {
darkMode: 'class',
// ...
}
Then in your CSS, define colours as custom properties for both themes:
/* assets/css/main.css */
:root {
--color-bg: #ffffff;
--color-bg-secondary: #f9fafb;
--color-text: #111827;
--color-text-muted: #6b7280;
--color-border: #e5e7eb;
}
.dark {
--color-bg: #0f172a;
--color-bg-secondary: #1e293b;
--color-text: #f1f5f9;
--color-text-muted: #94a3b8;
--color-border: #334155;
}
The composable
// composables/useColorMode.ts
export type ColorMode = 'light' | 'dark' | 'system'
export function useColorMode() {
const preference = useLocalStorage<ColorMode>('color-mode', 'system')
const isDark = computed(() => {
if (preference.value === 'system') {
// Check system preference
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
return preference.value === 'dark'
})
function applyMode() {
if (isDark.value) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
// Apply on preference change
watch(preference, applyMode)
// Watch system preference changes
onMounted(() => {
applyMode()
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', applyMode)
onUnmounted(() => mediaQuery.removeEventListener('change', applyMode))
})
function setMode(mode: ColorMode) {
preference.value = mode
}
return { preference, isDark, setMode }
}
Preventing flash of wrong theme
This is the hard part. If the user prefers dark mode and you apply it via JavaScript on onMounted, there's a brief flash of light mode before the script runs.
The solution: an inline script in the <head> that runs synchronously before the page renders.
In Nuxt, add this via app.head in your config:
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
script: [
{
innerHTML: `
(function() {
var stored = localStorage.getItem('color-mode');
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (stored === 'dark' || (stored !== 'light' && prefersDark)) {
document.documentElement.classList.add('dark');
}
})();
`,
type: 'text/javascript'
}
]
}
}
})
This runs before any CSS is applied, so the correct class is on <html> before the browser paints.
The toggle component
<!-- components/ColorModeToggle.vue -->
<script setup>
const { preference, setMode } = useColorMode()
function toggle() {
setMode(preference.value === 'dark' ? 'light' : 'dark')
}
</script>
<template>
<button
@click="toggle"
class="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
:aria-label="preference === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
>
<SunIcon v-if="preference === 'dark'" class="w-5 h-5" />
<MoonIcon v-else class="w-5 h-5" />
</button>
</template>
Using Nuxt Color Mode module
If you'd rather not implement this yourself, @nuxtjs/color-mode handles all of the above:
npx nuxi module add color-mode
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/color-mode'],
colorMode: {
classSuffix: '', // Use 'dark' not 'dark-mode'
preference: 'system', // Default to system
fallback: 'light'
}
})
Usage:
<script setup>
const colorMode = useColorMode()
// colorMode.preference: 'system' | 'light' | 'dark'
// colorMode.value: 'light' | 'dark' (resolved)
</script>
The module handles the anti-flash script, localStorage persistence, and system preference detection automatically.
Testing dark mode
Check for:
- Images with white backgrounds (jarring on dark backgrounds — use transparent PNGs or add a subtle background)
- SVG icons with hardcoded
fill="black"orfill="white"(usecurrentColorinstead) - Third-party embeds (iframes, widgets) that don't respect your theme
- Form autofill styles (browsers apply their own background colours which may not match)
Test in both modes with each release. Dark mode bugs are easy to miss if you only test the default.