The pain this guide solves
Nuxt renders HTML on the server but your theme colors live in JavaScript. The first paint shows the wrong colors, useHead runs too late, and every dark mode solution involves client-side flicker that you can't seem to eliminate.
salt-theme-gen with Nuxt 3
What you will build
- CSS variables generated at build time and injected via a Nuxt plugin
- Server-side rendering with no flash of unstyled content
- Dark mode using Nuxt’s built-in
useColorMode()composable - A
useTheme()composable for typed token access anywhere in the app
Time required: 15 minutes.
Install
npm install salt-theme-gen
Step 1 — Generate theme and CSS variables
Create lib/theme.ts at the project root:
import { generateTheme } from 'salt-theme-gen';
import type { GeneratedThemeMode } from 'salt-theme-gen';
export const theme = generateTheme({
preset: 'ocean',
spacing: 'default',
radius: 'default',
fontSize: 'default',
});
function kebab(str: string): string {
return str.replace(/([A-Z])/g, '-$1').toLowerCase();
}
function modeToVars(mode: GeneratedThemeMode): string {
const lines: string[] = [];
for (const [k, v] of Object.entries(mode.colors))
lines.push(`--color-${kebab(k)}: ${v};`);
for (const [k, v] of Object.entries(mode.surfaceElevation))
lines.push(`--surface-${k}: ${v};`);
for (const [k, v] of Object.entries(mode.spacing))
lines.push(`--space-${k}: ${v}px;`);
for (const [k, v] of Object.entries(mode.radius))
lines.push(`--radius-${k}: ${v}px;`);
for (const [k, v] of Object.entries(mode.fontSizes))
lines.push(`--text-${k}: ${v}px;`);
for (const [intent, states] of Object.entries(mode.states))
for (const [state, val] of Object.entries(states as Record<string, string>))
lines.push(`--state-${intent}-${state}: ${val};`);
return lines.join('\n ');
}
export const lightVars = modeToVars(theme.light);
export const darkVars = modeToVars(theme.dark);
export const themeCSS = `
:root { ${lightVars} }
.dark { ${darkVars} }
@media (prefers-color-scheme: dark) {
:root:not(.light) { ${darkVars} }
}
`.trim();
Nuxt’s useColorMode() applies a class (dark or light) to <html> rather than a data-theme attribute — the CSS targets .dark instead.
Step 2 — Inject via Nuxt plugin
Create plugins/theme.client.ts:
import { themeCSS } from '~/lib/theme';
export default defineNuxtPlugin(() => {
if (import.meta.server) return;
const styleEl = document.getElementById('salt-theme') as HTMLStyleElement | null
?? Object.assign(document.createElement('style'), { id: 'salt-theme' });
styleEl.textContent = themeCSS;
if (!styleEl.parentNode) {
document.head.appendChild(styleEl);
}
});
For SSR injection (the better option — eliminates FOUC entirely), use nuxt.config.ts to inline the CSS directly into the server-rendered HTML:
// nuxt.config.ts
import { themeCSS } from './lib/theme';
export default defineNuxtConfig({
app: {
head: {
style: [
{ children: themeCSS, type: 'text/css' },
],
},
},
});
The app.head.style array is rendered server-side in <head> before any JavaScript. This is the zero-FOUC approach — the CSS is in the HTML the browser receives, not added by a script.
Step 3 — Dark mode with useColorMode
Nuxt ships @nuxtjs/color-mode as a first-party module. Install it:
npm install @nuxtjs/color-mode
Add to nuxt.config.ts:
import { themeCSS } from './lib/theme';
export default defineNuxtConfig({
modules: ['@nuxtjs/color-mode'],
colorMode: {
classSuffix: '', // applies class 'dark' not 'dark-mode'
preference: 'system', // default: follow OS preference
fallback: 'light',
},
app: {
head: {
style: [{ children: themeCSS, type: 'text/css' }],
},
},
});
classSuffix: '' makes the module add class dark (not dark-mode) to match your .dark { } CSS selector.
Step 4 — Theme toggle component
<!-- components/ThemeToggle.vue -->
<template>
<button class="toggle" @click="toggleMode" :aria-label="`Switch to ${next} mode`">
{{ colorMode.value === 'dark' ? '☀ Light' : '◐ Dark' }}
</button>
</template>
<script setup lang="ts">
const colorMode = useColorMode();
const next = computed(() => colorMode.value === 'dark' ? 'light' : 'dark');
function toggleMode() {
colorMode.preference = next.value;
}
</script>
<style scoped>
.toggle {
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 6px 10px;
cursor: pointer;
color: var(--color-text);
font-size: var(--text-sm);
}
.toggle:hover { border-color: var(--color-primary); }
</style>
useColorMode() is auto-imported by Nuxt — no import statement needed.
Step 5 — Typed theme composable
Create composables/useTheme.ts:
import { theme } from '~/lib/theme';
import type { GeneratedThemeMode } from 'salt-theme-gen';
export function useTheme() {
const colorMode = useColorMode();
const mode = computed<GeneratedThemeMode>(() =>
colorMode.value === 'dark' ? theme.dark : theme.light,
);
return { theme, mode };
}
Nuxt auto-imports everything in composables/ — use useTheme() in any component without importing it.
Using tokens in components
In <style> blocks
<!-- pages/index.vue -->
<template>
<main class="home">
<h1 class="title">Welcome</h1>
<p class="subtitle">Built with salt-theme-gen tokens.</p>
</main>
</template>
<style scoped>
.home {
padding: var(--space-xxl);
background: var(--color-background);
}
.title {
font-size: var(--text-3xl);
font-weight: 800;
color: var(--color-text);
}
.subtitle {
font-size: var(--text-lg);
color: var(--color-muted);
}
</style>
In <script setup> with typed tokens
<template>
<div :style="cardStyle">
<h3 :style="{ color: mode.colors.text, fontSize: `${mode.fontSizes.lg}px` }">
{{ title }}
</h3>
</div>
</template>
<script setup lang="ts">
defineProps<{ title: string }>();
const { mode } = useTheme();
const cardStyle = computed(() => ({
background: mode.value.surfaceElevation.card,
border: `1px solid ${mode.value.colors.border}`,
borderRadius: `${mode.value.radius.lg}px`,
padding: `${mode.value.spacing.xl}px`,
}));
</script>
Global base styles
Create assets/global.css and import in nuxt.config.ts:
/* assets/global.css */
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
background-color: var(--color-background);
color: var(--color-text);
font-size: var(--text-md);
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
transition: background-color 0.2s, color 0.2s;
}
a { color: var(--color-primary); }
a:hover { color: var(--state-primary-hover); }
// nuxt.config.ts
export default defineNuxtConfig({
css: ['~/assets/global.css'],
// ...
});
Without @nuxtjs/color-mode
If you prefer not to use the module, manage dark mode manually:
// composables/useThemeMode.ts
export function useThemeMode() {
const isDark = useState('isDark', () => false);
function init() {
if (import.meta.server) return;
const saved = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
isDark.value = saved === 'dark' || (!saved && prefersDark);
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'light');
}
function toggle() {
isDark.value = !isDark.value;
const label = isDark.value ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', label);
localStorage.setItem('theme', label);
}
return { isDark, init, toggle };
}
And update the CSS in themeCSS to use data-theme attributes instead of classes:
export const themeCSS = `
:root { ${lightVars} }
:root[data-theme="dark"] { ${darkVars} }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { ${darkVars} }
}
`.trim();
File structure summary
lib/
└── theme.ts ← generateTheme() + CSS var export
plugins/
└── theme.client.ts ← optional client-side injection fallback
composables/
├── useTheme.ts ← typed token access (auto-imported)
└── useThemeMode.ts ← manual dark mode (if not using color-mode module)
assets/
└── global.css ← token-based base styles
nuxt.config.ts ← SSR style injection + module config
Checklist
lib/theme.tsgenerates CSS vars ✓nuxt.config.tsinjectsthemeCSSviaapp.head.style(server-side) ✓@nuxtjs/color-modeinstalled withclassSuffix: ''✓.dark { }block inthemeCSStargets Nuxt’s class toggle ✓composables/useTheme.tsauto-imported — no manual imports ✓assets/global.csslisted innuxt.config.tscssarray ✓
SSR vs client-only injection: The app.head.style approach in nuxt.config.ts is strongly preferred over the client plugin. It inlines the CSS in server-rendered HTML — the browser applies tokens before any JavaScript runs. Use the client plugin only as a fallback for dynamic runtime theme switching.
Live demo
Open this integration in StackBlitz — fully working, editable in your browser.
Open in StackBlitz →