Dark mode and theming
Name colours by role as CSS custom properties, offer light, dark and system with useColorMode(), and set the theme before the first paint so the page never flashes.
What you'll learn
When you finish, you can do these.- 1Name colours by role and switch them all with one class
- 2Offer light, dark and system with useColorMode()
- 3Set the theme before the first paint so nothing flashes
- 4Fix a card stuck white and a theme label Nuxt gets wrong
Picture a small online shop that sells mugs and teapots, with a dark theme for people who shop at night. Someone opens their order page on a phone set to dark mode. The page turns dark, except the order card, whose style says background: #fff, plain white. And on every reload, the page shows white for a moment before it turns dark.
The Code tab holds that order page, with the white card fixed, as one file, App.vue: the order card and a Theme menu with System, Light and Dark. By the end you can read every line of it, add a theme of your own, and write the small script that stops the flash. You need one idea from CSS in single-file components: the rules in a <style scoped> block reach only their own component's elements.
Name colours by role
The white card needs colours named by what they do, so each theme can fill in its own values. A named value the whole app shares is called a design token. Name colour tokens by role, not by look: --bg for the page, --surface for cards, --text and --muted for strong and quiet text, --accent for the card's heading.
They are CSS custom properties, the variables of CSS: declared on :root, which is the <html> element, and read with var(). The <style> block of App.vue declares the five tokens, then gives them dark values under html.dark, which matches <html> while it has the class dark:
:root {
--bg: #f7f6f2; --surface: #ffffff; --text: #171d26; --muted: #4e5661; --accent: #1566c5;
color-scheme: light;
}
html.dark {
--bg: #0b1015; --surface: #12181d; --text: #e6ecf2; --muted: #adb5be; --accent: #7fb0f0;
color-scheme: dark;
}
The dark theme changes values and nothing else. The card's rule keeps saying background: var(--surface), and one class on <html> flips every token at once. Calling the card's token --white would be a lie: in dark mode, it holds a dark grey.
color-scheme tells the browser which theme to use for the parts it draws itself: form controls, scrollbars and the default page colours. Without color-scheme: dark, the Theme menu keeps a light dropdown on a dark page.
These rules sit in a plain <style> block, not a scoped one: a scoped rule reaches only its own component's elements, and no component renders <html>.
Light, dark or system
The Theme menu offers three choices: light, dark, or whatever the device uses. The page asks the device through prefers-color-scheme, a media query that answers light or dark. useColorMode() from VueUse, a library of Vue helpers, combines that answer with a saved choice. The script of App.vue calls it once:
const { store, system, state } = useColorMode()
It gives back three refs, boxes that hold a value and tell Vue when it changes. store is the saved choice: 'auto', 'light' or 'dark'. It is kept in localStorage, the browser's storage that survives a reload, under the key vueuse-color-scheme. system is what the device prefers. state is what shows now: system while store is 'auto', otherwise store.
Whenever state changes, useColorMode() puts light or dark as a class on <html>, which is what html.dark selects.
The menu is a <select>, and v-model, which keeps a form field and a ref in step, ties its chosen option to store:
<select v-model="store">
<option value="auto">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
Under the menu, a line prints all three refs. On a phone set to dark, with System picked, it reads "Saved: auto · showing: dark · system prefers dark". Pick Light, and it says showing: light whatever the phone prefers.
The first paint
The flash on reload is a matter of timing. The browser's first drawing of the page, the first paint, happens as soon as the HTML arrives. useColorMode() usually runs later, when Vue mounts your app (puts it on the page). So the page shows light for a moment and then flips.
The class has to be on <html> before the first paint. Only a small inline script, written right inside the <head> of index.html, the page your app mounts into, runs that early.
- Inline scriptreads the saved choice
- Class on html
- First paint
- App mountsuseColorMode takes over
The playground has no index.html, so this script is not in the Code tab:
<script>
// In <head>: runs before the first paint, long before Vue loads
const saved = localStorage.getItem('vueuse-color-scheme') || 'auto'
const dark = saved === 'dark' || (saved === 'auto' && matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.add(dark ? 'dark' : 'light')
</script>
It reads the key useColorMode() writes, or auto when there is none. It picks dark for dark, or for auto on a device that prefers dark: matchMedia asks the media query from JavaScript. Then it puts the class on <html>, which JavaScript calls document.documentElement. When the app mounts, the two agree and nothing moves.
Keep it inline and leave out type="module": module scripts wait until the whole page has been parsed.
In Nuxt, a framework built on Vue, an add-on module called @nuxtjs/color-mode adds such a script for you.
What goes wrong
The first mistake is a raw colour, a value where a token belongs, such as the card's background in App.vue written as #fff. Pick Dark: the page turns dark, the text turns pale, and the card stays white with pale text on it.
.card { background: #fff; padding: 16px; border-radius: 12px; }
.card { background: var(--surface); padding: 16px; border-radius: 12px; }
No warning tells you. Search your styles for colour values outside the token block, and check every screen in both themes.
The second mistake needs Nuxt, so the Code tab cannot show it. Nuxt's @nuxtjs/color-mode module has its own useColorMode(), and in a template $colorMode.value is the theme that shows. Picture a small label on the order page that prints it, written as a component, ThemeLabel.vue:
<template>
<p>Theme: {{ $colorMode.value }}</p>
</template>
Nuxt renders each page on the server first, and the server cannot see the theme: localStorage never reaches it, and the device setting never leaves the device. So the server prints the module's default, system, and on a dark phone the label turns to dark after load: the same flash, in text.
Render it only in the browser with the module's <ColorScheme> component, which puts a placeholder in the server's HTML:
<template>
<ColorScheme placeholder="Theme: …" tag="p">
<p>Theme: {{ $colorMode.value }}</p>
</ColorScheme>
</template>
The server's HTML now says "Theme: …", and the browser fills in "Theme: dark".
v-if="!$colorMode.unknown" does the same job: unknown stays true until the browser takes over. The module's storage: 'cookie' option lets the server read a saved choice, but a visitor on System stays unknown.
What to remember
- Name colour tokens by role on
:root, give them new values underhtml.dark, and setcolor-schemein each theme so the browser's own controls match. useColorMode()keeps a saved choice (store), the device preference (system) and the result (state), and puts the class on<html>.useDark()is a two-position toggle.- An inline script in
<head>sets the class before the first paint; without it the page flashes the wrong theme. - A raw colour such as
#fffignores the theme, and nothing warns you. - The server cannot see the theme. Render theme-dependent text only in the browser, with
<ColorScheme>or$colorMode.unknownin Nuxt.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Add
--border: #dfe3e9;to:root,--border: #2a333d;tohtml.dark, andborder: 1px solid var(--border);to.card. Switch between Light and Dark: the card's outline changes with the theme. - Add a sepia theme. Call
useColorMode({ modes: { sepia: 'sepia' } }), add<option value="sepia">Sepia</option>, and add anhtml.sepiarule that sets every token, for example--bg: #f1e7d0; --surface: #f8f1e1; --text: #433422; --muted: #6b5a43; --accent: #8a4b14; color-scheme: light;. Pick Sepia: the page turns warm brown and the line under the menu saysshowing: sepia. - In
.card, replacevar(--surface)with#fffand pick Dark. The card stays white and its text turns pale. Put the token back and the card turns dark again.