Adding dark mode to an app that was styled one colour at a time is slow, because every component has to be opened and edited. In an app that names its colours it is quick: each colour is a CSS variable named by its role, one data-theme attribute on <html> swaps their values, and a small inline script sets that attribute before the first paint. Components contain no colour values, so they never need to know which theme is on.
The examples build light, dark and system themes into a Vue 3.5 app made with create-vue, with Tailwind CSS 4.1 and VueUse 14 installed; nothing here needs a UI library. In Nuxt, a module replaces the composable and the inline script; the last section sets it up.
Name colours by role, not by value
A token is a CSS custom property named after the job a colour does, not after the colour itself. --surface is the background of cards and panels; --muted is secondary text. Put the light set on :root in your main stylesheet, and make body read from it:
:root {
color-scheme: light;
--bg: #f7f6f2;
--surface: #ffffff;
--text: #171d26;
--muted: #4e5661;
--accent: #1566c5;
--on-accent: #ffffff;
--border: #d9dde3;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
}
Seven tokens are enough to start. --on-accent is the text colour that sits on the accent, for buttons; it earns its place in the contrast section below. color-scheme: light tells the browser which theme the page is in; the dark set flips it.
From now on components use tokens and nothing else. The mistake that makes dark mode hard is a colour written straight into a component, and it hides in scoped styles:
<style scoped>
.card {
background: #ffffff;
color: #171d26;
border: 1px solid #d9dde3;
}
</style>
The card stays white when the rest of the page turns dark, and someone has to find it. Written with tokens it never needs a second look:
<style scoped>
.card {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
</style>
A search for hex colours such as #fff inside <style> blocks is the review check. Every hit is a colour that will not change with the theme.
Swap the values with a data-theme attribute
A person can want light, dark, or whatever the system says. Represent the resolved answer as an attribute on <html>: data-theme="light" or data-theme="dark". The dark set is one more block in main.css that redefines the same names:
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #0b1015;
--surface: #12181d;
--text: #e6ecf2;
--muted: #adb5be;
--accent: #5f9ae8;
--on-accent: #0b1015;
--border: #273038;
}
The color-scheme: dark line makes native controls follow the theme: date inputs, <select> dropdowns, checkboxes and the scrollbar render in the dark palette. Without it a dark page gets a white scrollbar and a glaring dropdown.
Nothing else in the stylesheet mentions dark mode. The system preference is handled where the attribute is set, by reading the prefers-color-scheme media query in JavaScript, which keeps the logic in one place.
If you also want the dark set to apply when JavaScript is off, copy the block under @media (prefers-color-scheme: dark) with the selector :root:not([data-theme="light"]), so an explicit light choice still wins.
Follow the system until the person chooses
The composable holds the person's choice, resolves system against the media query, writes the attribute, and saves the choice. VueUse's useMediaQuery returns a ref that updates when the operating system switches theme, so a page left open at sunset follows along. createSharedComposable makes every component share one copy of the state:
import { createSharedComposable, useMediaQuery } from '@vueuse/core'
import { computed, ref, watchEffect } from 'vue'
export type ThemeChoice = 'light' | 'dark' | 'system'
const STORAGE_KEY = 'theme'
function readChoice(): ThemeChoice {
const saved = localStorage.getItem(STORAGE_KEY)
return saved === 'light' || saved === 'dark' ? saved : 'system'
}
export const useTheme = createSharedComposable(() => {
const choice = ref<ThemeChoice>(readChoice())
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)')
const resolved = computed(() =>
choice.value === 'system' ? (prefersDark.value ? 'dark' : 'light') : choice.value,
)
watchEffect(() => {
document.documentElement.dataset.theme = resolved.value
})
function setTheme(next: ThemeChoice) {
choice.value = next
if (next === 'system') localStorage.removeItem(STORAGE_KEY)
else localStorage.setItem(STORAGE_KEY, next)
}
return { choice, resolved, setTheme }
})
choice is what the person picked and resolved is what the page shows. Keep both, because a switch control must show "System" as selected even while the page is dark.
Choosing system removes the key rather than storing the word, so the absence of a key means "follow the system" everywhere, including in the inline script below.
The switch itself is three radio buttons, which gives you keyboard support and a label for free:
<script setup lang="ts">
import { useTheme, type ThemeChoice } from '@/composables/useTheme'
const { choice, setTheme } = useTheme()
const options: { value: ThemeChoice; label: string }[] = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' },
]
</script>
<template>
<fieldset>
<legend>Theme</legend>
<label v-for="option in options" :key="option.value">
<input
type="radio"
name="theme"
:value="option.value"
:checked="choice === option.value"
@change="setTheme(option.value)"
>
{{ option.label }}
</label>
</fieldset>
</template>
If the switch is not on every page, call useTheme() in App.vue too. createSharedComposable stops the shared state when the last component using it unmounts, and from then on the page no longer follows a change of system theme.
VueUse also ships useDark() and useColorMode(), which do the same job with a class on <html>. Writing the composable once yourself shows you exactly what they do; after that, use whichever you prefer.
Stop the flash of the wrong theme on load
The composable runs once your bundle has loaded, and the browser can paint the page before that. A person who chose dark then sees a white page for a moment on every load. The fix is a script small enough to inline in index.html, placed in <head> so it runs before the first paint. It is a plain script, not a module, because module scripts wait until the page has been parsed:
<script>
(function () {
var saved = localStorage.getItem('theme')
var dark = saved === 'dark' || (saved !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
})()
</script>
It applies the same rule as the composable: a saved dark or light wins, and with no saved key the system setting decides. Keep the key and the logic identical in both places, or the script and the composable will disagree for a frame and you get the flash back.
If your site sends a strict Content Security Policy, the inline script needs a nonce or a hash to run. Add one rather than dropping the script.
Turn the tokens into Tailwind 4 utilities
Tailwind 4 reads its design tokens from CSS. This block goes at the top of main.css, above the token blocks. The @custom-variant line makes the dark: variant follow the attribute instead of the media query, using the syntax from the dark mode docs. The @theme block registers your tokens as colours, so utilities exist for them:
@import "tailwindcss";
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
@theme inline {
--color-bg: var(--bg);
--color-surface: var(--surface);
--color-ink: var(--text);
--color-muted: var(--muted);
--color-accent: var(--accent);
--color-on-accent: var(--on-accent);
--color-border: var(--border);
}
Now bg-surface, text-muted, border-border and bg-accent text-on-accent are real utilities, and they change with the theme because they resolve to your variables. The text token is registered as ink only so that the utility reads text-ink rather than text-text.
The inline keyword makes each utility read your variable directly: bg-surface compiles to background-color: var(--surface). Without it, Tailwind defines --color-surface: var(--surface) on :root and the utility reads var(--color-surface). The browser resolves that reference on :root, so a token you redefine on an element lower in the page would not reach the utility. The theme docs show the same trap with a font variable.
With tokens doing the work, dark: is rarely needed; keep it for the one-offs, such as <img class="dark:invert"> on a black logo. Tailwind also has scheme-light and scheme-dark utilities if you prefer to set color-scheme in markup.
Check contrast in both themes
The Web Content Accessibility Guidelines (WCAG) ask at level AA for a contrast ratio of 4.5:1 for normal text and 3:1 for large text and interface parts. The palette above was chosen with those numbers: body text on a surface is 16.9:1 in light and 15.0:1 in dark, and muted text is 7.4:1 and 8.6:1.
The trap is the button. White on the light accent is 5.6:1, fine; white on the lighter blue the dark theme needs is 2.9:1, a failure. That is why --on-accent is a token: in the dark set it flips to the dark background colour, which gives 6.6:1. Any colour that sits on another colour needs its own token, because the pair changes together.
To measure a pair yourself, open the colour picker for a text colour in Chrome or Firefox DevTools: it shows the contrast with the background. If you run the axe scan from last week's post on Playwright tests, add await page.emulateMedia({ colorScheme: 'dark' }) and run it a second time; a color-contrast violation in one theme is invisible in the other.
Use @nuxtjs/color-mode in Nuxt
A server-rendered app has an extra problem: the server renders the HTML without knowing the visitor's choice, which lives in the browser. The @nuxtjs/color-mode module solves it with its own inline script and a composable. Install it with npx nuxt module add color-mode and point it at the attribute the CSS already uses:
export default defineNuxtConfig({
modules: ['@nuxtjs/color-mode'],
colorMode: {
preference: 'system',
fallback: 'light',
dataValue: 'theme',
storageKey: 'theme',
},
})
dataValue: 'theme' writes data-theme="dark" on <html>, so the stylesheet from this post works unchanged; the module also adds a dark or light class. preference is the starting choice for a new visitor, fallback the theme used when the browser reports no system preference, and storageKey keeps the key used above.
In a component, const colorMode = useColorMode() gives you colorMode.preference, which you set to 'light', 'dark' or 'system', and colorMode.value, the resolved theme.
During server rendering colorMode.unknown is true and the resolved value is not available yet. Markup that differs by theme, such as a sun or moon icon, goes inside the module's <ColorScheme> component, which renders it only in the browser, to avoid a hydration mismatch.