Vue Dark Mode, Theming and Design Tokens in Vue Apps
AI generated
<v/>
{ }
Vue 3 · Dark Mode · Design Tokens · Theming
Vue Dark Mode, Theming
and Design Tokens in Vue Apps

Implementing dark mode with a simple class toggle is the first step. A professional theme system with design tokens, system preference detection, persistence and flicker-free SSR loading is what actually benefits users in practice. Vue 3 provides all the building blocks, the point is putting them together correctly.

14 min read CSS Custom Properties · Tailwind · useTheme · Pinia · prefers-color-scheme Vue 3.x · Tailwind CSS v3/v4 · Nuxt 3

1. What design tokens are and why they simplify dark mode

Design tokens are named, semantic design decisions expressed as variables: --color-surface instead of #ffffff, --color-text-primary instead of #111827, --color-border instead of #e5e7eb. The crucial difference from plain color values: design tokens describe the role of a color, not its visual value. --color-surface is white in light mode and dark gray in dark mode, the name stays the same, only the value changes depending on the theme.

Without design tokens, dark mode implementation looks like this: every component has explicit dark mode classes such as dark:bg-gray-900 dark:text-white. That works, but it scales poorly. With a hundred components, a theme change means a hundred manual adjustments. With design tokens as CSS custom properties there is a single place where the dark mode theme is defined. Every component that uses --color-surface automatically gets the dark mode value. That is the fundamental advantage of the token based approach.

In the context of Vue 3 and dark mode, design tokens are implemented as CSS custom properties that get overridden on the :root element or on the html element via a data-theme or class selector. That makes it possible to switch the theme through a simple DOM attribute without re-rendering JavaScript. Vue only reacts to the state that controls the attribute, the CSS switch happens natively in the browser.

2. CSS custom properties as the design token layer

Implementing design tokens with CSS custom properties starts with a semantic token hierarchy. At the lowest level sit primitive tokens: concrete color values such as --green-600: #16a34a. At the next level, semantic tokens: --color-primary: var(--green-600). At the top level, component-specific tokens: --button-background: var(--color-primary). This hierarchy makes it possible to switch the theme at the semantic level without duplicating every primitive token.

The dark mode theme only overrides the semantic tokens. In :root, the light mode values are defined. In the [data-theme="dark"] or .dark selector, only the semantic tokens get overridden. Primitive tokens stay unchanged because they carry no meaning in the dark mode context. This structure makes design token management scalable: new colors are defined once as primitive tokens and then wired into semantic tokens.


/* src/styles/tokens.css */
/* Design Token layer: primitives and semantic tokens */

:root {
  /* Primitive tokens (never use directly in components) */
  --green-50: #f0fdf4;
  --green-600: #16a34a;
  --green-700: #15803d;
  --slate-50: #f8fafc;
  --slate-900: #0f172a;
  --white: #ffffff;

  /* Semantic tokens: Light Mode defaults */
  --color-bg: var(--white);
  --color-surface: var(--slate-50);
  --color-text-primary: var(--slate-900);
  --color-text-muted: #64748b;
  --color-border: #e2e8f0;
  --color-primary: var(--green-600);
  --color-primary-hover: var(--green-700);

  /* Component tokens */
  --card-bg: var(--color-bg);
  --card-border: var(--color-border);
  --nav-bg: var(--color-surface);
}

/* Dark Mode: only override semantic tokens */
[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-surface: #1e293b;
  --color-text-primary: #f1f5f9;
  --color-text-muted: #94a3b8;
  --color-border: #334155;
  /* --color-primary stays green, same brand color in both themes */
}

3. The useTheme composable: encapsulating dark mode in Vue 3

The useTheme composable is the centerpiece of the Vue dark mode system. It encapsulates the entire theme lifecycle: reading the system preference, checking LocalStorage, setting the active theme, updating the data-theme attribute on the html element, and reacting to system preference changes. Components that want to display or toggle the theme simply call useTheme() and get back theme, isDark and toggleTheme.

The DOM write, document.documentElement.setAttribute('data-theme', theme.value), should happen inside a watchEffect that reacts to the reactive theme state. That decouples the Vue state from the DOM manipulation: the theme state is managed reactively, and the watch effect synchronizes the state into the DOM. This is cleaner than writing to the DOM directly inside methods, because the watch effect also runs on the initial composable call and thus sets the initial theme correctly.

For global use, where every component needs access to the same theme, the composable is implemented either as a composable store (module-scope state) or as a Pinia store. For SSR applications the Pinia store is preferable. For client-only applications the composable store approach with module-scope state, which holds the active theme as a ref outside the composable function, is sufficient.


// src/composables/useTheme.ts
import { ref, computed, watchEffect, onMounted } from 'vue'

type Theme = 'light' | 'dark' | 'system'
type ResolvedTheme = 'light' | 'dark'

// Module-scope state: shared across all component instances (client-only)
const activeTheme = ref<Theme>('system')

export function useTheme() {
  const resolvedTheme = computed<ResolvedTheme>(() => {
    if (activeTheme.value === 'system') {
      return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
    }
    return activeTheme.value
  })

  const isDark = computed(() => resolvedTheme.value === 'dark')

  // Sync reactive state to DOM attribute + persist to localStorage
  watchEffect(() => {
    if (typeof document === 'undefined') return  // SSR guard
    document.documentElement.setAttribute('data-theme', resolvedTheme.value)
    localStorage.setItem('theme', activeTheme.value)
  })

  // Read persisted preference after mount (not during SSR)
  onMounted(() => {
    const persisted = localStorage.getItem('theme') as Theme | null
    if (persisted) activeTheme.value = persisted
  })

  function setTheme(theme: Theme) { activeTheme.value = theme }
  function toggleTheme() {
    activeTheme.value = isDark.value ? 'light' : 'dark'
  }

  return { theme: activeTheme, resolvedTheme, isDark, setTheme, toggleTheme }
}

4. Detecting system preference: prefers-color-scheme and matchMedia

The media query feature prefers-color-scheme is the browser standard for detecting system dark mode. window.matchMedia('(prefers-color-scheme: dark)').matches returns true when the operating system or browser has dark mode enabled. That is the starting point for the dark mode default: if no stored theme exists, the application follows the system preference. Users who have dark mode enabled system-wide get it automatically, with no manual setting required.

To react to changes in system preference, when the user switches between dark and light mode in the operating system, the composable registers an addEventListener on the matchMedia object. The change event fires when the user changes the system theme. In the composable cleanup (onUnmounted) the listener is removed again. As long as the active theme is 'system', the Vue dark mode implementation reacts to these changes live, without a page reload.

An important UX consideration: the 'system' theme should not appear as a separate value in a toggle button, but as the implicit default behavior. The UX recommendation: a toggle button switches between 'light' and 'dark'. A separate "reset" button sets the theme back to 'system'. That matches actual usage patterns: most users either want to explicitly force light or dark, or let the system decide.

5. Theme persistence: LocalStorage and Pinia integration

The chosen theme has to persist across page loads. The simplest persistence method: localStorage.setItem('theme', theme) when setting the theme, and localStorage.getItem('theme') during initialization. That works reliably for client-only applications. The challenge with SSR: localStorage is not available on the server, and the initial HTML response has no knowledge of the user's theme, which leads to the flash-of-wrong-theme problem.

For Pinia based theme persistence, the plugin pinia-plugin-persistedstate is a good fit: it automatically serializes the theme state into LocalStorage and hydrates it on start. In Nuxt, cookies can be used instead of LocalStorage, because the server can read the cookie on every request and apply the theme server-side. That eliminates the theme flash entirely, because the server already renders the correct data-theme attribute in the HTML.

6. Tailwind CSS dark mode: class vs. media strategy

Tailwind CSS offers two strategies for dark mode: media and class. The media strategy activates dark mode variants automatically based on the system prefers-color-scheme media query, with no JavaScript and no DOM manipulation. The class strategy activates dark mode variants when a specific class (dark by default) is present on a parent element. For Vue applications with a manual theme toggle and persistence, class is the right choice, because it allows programmatic control over the active theme.

Tailwind CSS v4 allows combining CSS custom properties and dark mode variants especially elegantly. With @custom-media --dark (prefers-color-scheme: dark) and token definitions in CSS, the design token system can be used directly in Tailwind classes. Design tokens as CSS custom properties and Tailwind dark mode classes are not competitors, they complement each other: tokens for consistent base colors across components, Tailwind dark mode classes for precise component-specific adjustments.


// tailwind.config.ts: class-based dark mode for programmatic control
import type { Config } from 'tailwindcss'

export default {
  // class strategy: dark mode activated by .dark class on html element
  darkMode: ['class', '[data-theme="dark"]'],
  content: ['./index.html', './src/**/*.{vue,ts,tsx}'],
  theme: {
    extend: {
      // Map design tokens to Tailwind utilities
      colors: {
        bg: 'var(--color-bg)',
        surface: 'var(--color-surface)',
        'text-primary': 'var(--color-text-primary)',
        'text-muted': 'var(--color-text-muted)',
        border: 'var(--color-border)',
        primary: 'var(--color-primary)',
      },
    },
  },
} satisfies Config

// Usage in components: semantic token classes instead of hardcoded colors
// bg-bg           → var(--color-bg)       → white / dark: #0f172a
// text-text-primary → var(--color-text-primary) → slate-900 / dark: #f1f5f9
// dark:bg-surface  → also possible for component-specific overrides

7. Multiple themes: beyond dark and light

The design token system built on CSS custom properties scales without trouble beyond two themes. Instead of just light and dark, a theme system can support ocean, forest, contrast-high and print, each theme overrides the same semantic tokens with different values. The data-theme selector approach allows any number of themes without a Tailwind configuration change: [data-theme="ocean"], [data-theme="forest"], and so on.

The useTheme composable only needs to know the theme list and have its type definition adjusted for this. The CSS layer takes care of the entire visual implementation. That makes it possible to add themes without any JavaScript changes, register a new CSS token set for a new theme and it is immediately available in the theme switcher. High-contrast themes for accessibility follow the same pattern and can even use the prefers-contrast: high media query as an automatic activation criterion.

8. Solving the flash-of-wrong-theme problem with SSR

Flash-of-wrong-theme (FOWT) is the best known problem with dark mode under SSR. The server renders HTML with no knowledge of the user's theme. The browser briefly shows the wrong theme (usually light mode) before JavaScript reads the theme from LocalStorage and updates the DOM. The result: a visible flicker on page load that reads as unpolished.

The most robust solution for the FOWT problem is an inline script in the <head> that runs before rendering. This script reads LocalStorage and sets the data-theme attribute synchronously, before the browser starts rendering. Because it sits in the <head> and has no defer or async, it briefly blocks parsing, but that is intentional in this case, because it prevents the theme flicker. In Nuxt, this script gets inserted via useHead() with script: [{ children: themeInitScript, tagPosition: 'head' }].

The cookie based alternative sends the theme preference cookie with every request. The server reads the cookie and renders the correct data-theme attribute directly in the HTML. That way the user never sees the wrong theme, even with JavaScript disabled. This is the technically cleanest solution to the FOWT problem, but it requires server-side cookie handling, which is not possible in static hosting environments.

9. Comparing theme strategies

Choosing the right dark mode and theming strategy depends on the project requirements. For simple projects with few components, Tailwind's dark mode classes without design tokens are enough. For medium to large projects, CSS custom properties as design tokens are the right approach. For SSR projects, cookie persistence is the only method that fully prevents FOWT.

Strategy Scaling SSR flash Multiple themes
Tailwind dark: classes Poor (a hundred places) Flash possible Not supported
CSS Custom Properties Excellent Flash with inline script Any number of themes
Cookie + SSR Excellent No flash Supported
Media query only Good No flash System preference only
Design Tokens + Pinia Excellent Inline script needed Fully supported

The recommended combination for new Vue 3 projects with dark mode: CSS custom properties as design tokens, Tailwind with darkMode: ['class', '[data-theme="dark"]'] for utility classes, a useTheme composable or Pinia store for Vue-side state management, and an inline script in the <head> for FOWT prevention. With SSR under Nuxt: cookie persistence instead of LocalStorage.

Mironsoft

Vue 3 theming, design token systems and dark mode integration

Professional theming for your Vue 3 app?

We implement a complete design token system with dark mode, system preference detection, persistence and FOWT prevention, for Tailwind CSS and CSS custom properties based Vue 3 apps.

Token system

CSS custom properties design token hierarchy for consistent theming across all components

Dark mode

Implementing the useTheme composable, system preference, persistence and FOWT prevention

Multi-theme

Building an extended theme system for branding, accessibility and custom themes

10. Summary

A professional Vue dark mode and theming system rests on three layers: CSS custom properties as design tokens for the visual implementation, a useTheme composable for Vue-side reactivity and state management, and persistence plus FOWT prevention for a seamless user experience. Design tokens turn a theme switch into a one-line CSS change instead of a hundredfold component modification. The useTheme composable encapsulates system preference detection, toggle logic and DOM synchronization.

The flash-of-wrong-theme problem is the most common quality issue in dark mode implementations, and it is solved with an inline script in the <head> or cookie persistence for SSR. Tailwind CSS and CSS custom properties are not competing approaches, they complement each other: tokens for system-wide design decisions, Tailwind utilities for component-specific adjustments. The resulting system scales from two themes to any number, without ever touching the component layer.

Vue Dark Mode and Design Tokens, the essentials at a glance

Design token hierarchy

Primitive tokens → semantic tokens → component tokens. Dark mode only overrides the semantic layer. Any number of themes without component changes.

useTheme composable

System preference via matchMedia, DOM sync via watchEffect, persistence via LocalStorage. Module-scope state for global sharing.

FOWT prevention

Inline script in the head before the first render. With SSR: cookie persistence for server-side theme rendering with no flash.

Tailwind integration

darkMode: class with data-theme selector. CSS custom properties as Tailwind color mapping. Tokens and utilities complement each other.

11. FAQ: Vue Dark Mode and Design Tokens

1What are design tokens for dark mode?
Semantically named variables (--color-surface) instead of color values. Dark mode overrides token values once, all components switch automatically.
2Flash-of-wrong-theme?
The server renders with no knowledge of the theme. The browser briefly shows the wrong theme. Solution: inline script in the head, or cookie persistence with SSR.
3Tailwind class vs. media?
media: automatic via prefers-color-scheme. class: programmatic via a DOM class. For a Vue app with persistence: choose the class strategy.
4Detect system preference?
window.matchMedia('(prefers-color-scheme: dark)').matches. For reactive updates: addEventListener('change') in onMounted, remove it in onUnmounted.
5Persist the theme?
LocalStorage for client-only. Cookie for SSR (the server can read it). pinia-plugin-persistedstate automates LocalStorage persistence.
6More than two themes?
CSS custom properties scale to any number. [data-theme='ocean'], [data-theme='forest'], new themes without JavaScript changes.
7Primitive vs. semantic tokens?
Primitive: concrete values (#16a34a). Semantic: roles (--color-primary). Dark mode only overrides semantic ones. Primitives are theme-independent.
8CSS custom properties in Tailwind?
theme.extend.colors: { surface: 'var(--color-surface)' }. Then use bg-surface as a Tailwind class. Token changes automatically affect all utilities.
9Pinia needed for the theme?
No. A composable store is enough for client-only. Pinia is useful for SSR isolation, DevTools needs, or plugin-based persistence.
10Prevent FOWT without an inline script?
Cookie persistence with SSR: the server reads the cookie and renders data-theme directly in the HTML. No JavaScript run needed for the correct theme.