Dark mode looks trivial until you actually ship it. Toggle a class, flip some CSS variables, done — except your Next.js page flashes white for 40 milliseconds before hydration finishes, or your Nuxt app forgets the user’s choice on every route change. The gap between “add dark mode” and “add dark mode that doesn’t flicker, persist correctly, and respect the OS setting” is where most React and Vue teams lose an afternoon. This tutorial closes that gap for both frameworks, using the tools each ecosystem actually reaches for: next-themes on the React side and VueUse or @nuxtjs/color-mode on the Vue side.
By the numbers, next-themes pulls roughly 18.1 million weekly npm downloads as of the first week of September 2026, against 9.92 million for @vueuse/core (the package that ships Vue’s useDark and useColorMode composables) and 581,000 for the Nuxt-specific @nuxtjs/color-mode module. That’s close to a 1.8x download gap between the two ecosystems’ most popular theming tools, and it reflects a real architectural difference: React’s dark mode story consolidates around one dominant library, while Vue’s is split between a general composable utility and a framework-specific module. If you’ve already compared the two frameworks on other fronts, our React vs Vue 2026 comparison covers the broader picture. This guide focuses on one narrow, concrete problem: building dark mode that actually works in production, in twelve steps, in under 90 minutes.
What makes dark mode deceptively hard isn’t the CSS. It’s the timing. A server-rendered page has to decide what to paint before it knows what the client’s stored preference is, a client-only app has to decide what to paint before its own JavaScript bundle has even parsed, and both frameworks have shipped very different answers to that timing problem. This tutorial walks through the React answer with Next.js and next-themes, the Vue answer with Nuxt and @nuxtjs/color-mode, and then strips both meta-frameworks away to show what dark mode looks like in a plain Vite app on either side, since a meaningful share of production React and Vue apps never touch Next.js or Nuxt at all.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Prerequisites: Versions and Tools for This Build
Version drift is the number one reason dark mode tutorials go stale within a few months. Tailwind CSS v4 moved dark mode configuration out of tailwind.config.js entirely, which breaks almost every pre-2026 tutorial that still references a JavaScript config file. Here’s what this walkthrough is built and tested against, pulled directly from the npm registry the week of September 1-6, 2026.
| Tool | Version Used Here | Weekly npm Downloads | Role |
|---|---|---|---|
| React | 19.2.8 | — | UI library |
| Next.js | 16.3.4 | — | React meta-framework |
| next-themes | 0.4.6 | 18,102,551 | React/Next.js theme switching |
| Vue | 3.5.42 | — | UI framework |
| Nuxt | 4.5.2 | — | Vue meta-framework |
| @nuxtjs/color-mode | 4.0.1 | 581,081 | Nuxt-specific theme module |
| @vueuse/core | 14.4.0 | 9,924,968 | Vue composables incl. useDark |
| Tailwind CSS | 4.3.3 | — | Utility CSS, dark: variant |
| Vite | 8.2.2 | — | Build tool for plain SPA setups |
You’ll also need Node.js 22.12 or newer (Next.js 16 and Nuxt 4 both require it), a package manager (npm, pnpm, or bun all work), and a code editor with TypeScript support. Nothing here needs a paid API key or cloud account. Everything runs locally.
How Dark Mode Actually Works Under the Hood
Before touching either framework, it helps to separate three things that “dark mode” bundles together, because most bugs come from conflating them. First, there’s detection: reading the user’s OS-level preference through the prefers-color-scheme CSS media feature, which every modern browser exposes and which MDN documents in detail in its prefers-color-scheme reference. Second, there’s application: how your CSS actually switches appearance, either through the media query directly or through a class or attribute strategy where you toggle .dark on the html element and let Tailwind’s dark: variant key off that class instead. Third, there’s persistence: remembering the user’s explicit override across page loads, usually in localStorage or a cookie so server-rendered pages can read it before the first paint.
The class or attribute strategy wins in production for one reason: it lets users override the OS setting. A media-query-only approach can’t distinguish “the OS is in dark mode” from “the user wants dark mode regardless of what the OS says.” Both next-themes and @nuxtjs/color-mode default to the class strategy with a “system” option layered on top, which is why they’ve become the standard rather than a hand-rolled media query. web.dev’s guide to prefers-color-scheme is worth reading if you want the full rationale before committing to an approach.
There’s a fourth piece worth naming separately: hydration, which only exists in server-rendered apps. When Next.js or Nuxt renders your page on the server, it has no access to the browser’s localStorage or its OS-level color scheme, so the server has to render something, then the client corrects it once JavaScript loads. Handled carelessly, that correction causes the visible flash this tutorial spends an entire step eliminating. Handled well, by reading a cookie on the server instead of guessing, that correction never has to happen at all. Every design decision in next-themes and @nuxtjs/color-mode traces back to managing that specific handoff correctly, which is also why hand-rolling a Context-based solution for a server-rendered app is meaningfully harder than doing the same thing in a client-only Vite SPA.
Step 1-4: Building Dark Mode in Next.js With next-themes
Step 1: Scaffold the Next.js project
Start with a fresh App Router project. next-themes is built specifically around the App Router’s server and client component split, so this isn’t the moment to reach for the Pages Router unless you’re retrofitting an existing app.
npx create-next-app@latest react-dark-mode --typescript --tailwind --app
cd react-dark-mode
npm install [email protected]
Step 2: Wrap the app in a ThemeProvider
next-themes ships a client component called ThemeProvider that wraps its internal context and handles hydration safely. Create a small wrapper component so you can mark it "use client" without forcing your whole layout to be client-rendered.
// components/theme-provider.tsx
"use client"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return {children}
}
Then wire it into the root layout, and — this is the step almost everyone skips — add suppressHydrationWarning to the html tag. next-themes sets the theme class before React hydrates, which means the server-rendered markup and the first client render legitimately differ. Without that attribute, React logs a hydration mismatch warning on every page load.
// app/layout.tsx
import { ThemeProvider } from "@/components/theme-provider"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
Step 3: Build the React theme toggle
The useTheme hook from next-themes returns the active theme and a setter. The tricky part isn’t the hook itself, it’s that the component reading it must not render theme-dependent UI until after mount, because the server doesn’t know the client’s actual theme yet.
"use client"
import { useTheme } from "next-themes"
import { useEffect, useState } from "react"
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null
return (
)
}
Step 4: Configure Tailwind CSS v4’s dark variant
Tailwind v4 dropped the JavaScript config approach for dark mode, a change documented in Tailwind’s own dark mode configuration guide. Instead of setting darkMode: "class" in tailwind.config.js, you declare a custom variant directly in your CSS entry file using @custom-variant. This single line is what makes dark: utilities respond to the class next-themes toggles, rather than the raw OS media query.
/* app/globals.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Once that’s in place, className="bg-white dark:bg-neutral-900" behaves exactly the way it did under Tailwind v3’s class strategy, just configured in CSS instead of JavaScript. If you’re also setting up a component library on top of this, our shadcn/ui vs shadcn-vue comparison walks through the same v4 dark-mode wiring in more detail for component-driven UIs.
Step 5-8: Building Dark Mode in Nuxt With @nuxtjs/color-mode
Step 5: Scaffold the Nuxt project and install the module
Nuxt takes a module-based approach where React takes a provider-component approach. Instead of wrapping your tree in a context provider, you register a Nuxt module in the config file and it injects a composable and a build-time script automatically.
npx nuxi@latest init vue-dark-mode
cd vue-dark-mode
npm install @nuxtjs/[email protected] @nuxtjs/tailwindcss
Step 6: Register the module in nuxt.config.ts
// nuxt.config.ts
export default defineNuxtConfig({
modules: ["@nuxtjs/color-mode", "@nuxtjs/tailwindcss"],
colorMode: {
classSuffix: "",
preference: "system",
fallback: "light",
storageKey: "nuxt-color-mode",
},
})
Setting classSuffix: "" matters more than it looks. By default the module applies .dark-mode and .light-mode classes with the suffix appended. Leaving that default in place means your Tailwind @custom-variant selector has to match .dark-mode instead of the conventional .dark, which trips up anyone copying Tailwind’s own documentation examples verbatim.
Step 7: Build the Vue theme toggle
The module auto-imports a color-mode composable, so there’s no manual provider wiring needed, a meaningful contrast with the React setup, where you write the provider and hydration guard yourself.
<script setup lang="ts">
const colorMode = useColorMode()
function toggle() {
colorMode.preference = colorMode.value === "dark" ? "light" : "dark"
}
</script>
<template>
<button @click="toggle" aria-label="Toggle dark mode">
{{ colorMode.value === "dark" ? "Light mode" : "Dark mode" }}
</button>
</template>
Step 8: Configure the Tailwind dark variant for Nuxt
/* assets/css/main.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
Because classSuffix was set to an empty string in step 6, the module now applies a plain .dark class, matching this selector exactly. Skip that config line and this variant silently never fires, which is one of the more common pitfalls covered later in this guide.
Step 9: Dark Mode in Plain Vite + React, No Next.js
Not every React project runs Next.js, and next-themes’ hydration-safe design is specifically solving a server-rendering problem you don’t have in a client-only Vite SPA. For a plain Vite + React app, a small custom context is lighter and easier to reason about than pulling in next-themes anyway.
// ThemeContext.tsx
import { createContext, useContext, useEffect, useState } from "react"
type Theme = "light" | "dark"
const ThemeContext = createContext<{ theme: Theme; toggle: () => void } | null>(null)
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState(
() => (localStorage.getItem("theme") as Theme) ??
(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
)
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark")
localStorage.setItem("theme", theme)
}, [theme])
const toggle = () => setTheme((t) => (t === "dark" ? "light" : "dark"))
return {children}
}
export const useTheme = () => {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider")
return ctx
}
This is roughly 25 lines versus next-themes’ zero lines of your own logic, which is the real trade-off between the two approaches: a dependency that solves SSR hydration correctly out of the box, or a hand-rolled context that’s trivial in a client-only app but would need real work to become SSR-safe. React’s useContext documentation covers the underlying pattern if this is your first time reaching for context this way.
Step 10: Dark Mode in Plain Vite + Vue, No Nuxt
The Vue equivalent doesn’t need a custom context at all, because @vueuse/core’s useDark composable already does exactly what the React example above does by hand: read the system preference, apply a class, and persist the choice, in three lines.
npm install @vueuse/[email protected]
<script setup lang="ts">
import { useDark, useToggle } from "@vueuse/core"
const isDark = useDark({ storageKey: "theme" })
const toggleDark = useToggle(isDark)
</script>
<template>
<button @click="() => toggleDark()" aria-label="Toggle dark mode">
{{ isDark ? "Light mode" : "Dark mode" }}
</button>
</template>
This is the clearest illustration of the philosophical split between the two ecosystems in this whole comparison. React’s dominant answer to “how do I persist state across a page reload” is generally “write it yourself with useEffect and localStorage,” while Vue’s composables ecosystem, documented in Vue’s own composables guide, tends to ship the entire pattern as an importable function. Neither is objectively better. The trade-off is dependency surface area versus lines of code you own.
Step 11: Eliminating the Flash of Wrong Theme (FOUC)
Here’s the bug that ships to production more often than any other in this guide: a user with dark mode saved loads your page, and for a fraction of a second they see a white flash before JavaScript runs and applies the dark class. This happens because the browser paints the initial HTML before any script executes, and if that HTML doesn’t already carry the right class, there’s nothing you can do about it after the fact except minimize the delay.
next-themes solves this automatically by injecting a tiny blocking script into the document head that runs before paint. If you’re building the plain Vite + React version from step 9, you need to add that script yourself, directly in your index.html, before your bundle loads.
<!-- index.html, inside <head>, before any other script -->
<script>
(function () {
var stored = localStorage.getItem("theme");
var isDark = stored === "dark" ||
(!stored && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", isDark);
})();
</script>
Vue’s useDark composable has the same problem in a plain Vite SPA and needs the identical inline script, since VueUse can’t inject anything before your JavaScript bundle parses. In Nuxt, @nuxtjs/color-mode handles this for you at the module level, similar to next-themes. This is one more spot where the meta-framework tooling earns its keep over the bare-composable approach.
Cookie-Based Theme Storage for True Zero-Flash SSR
The blocking script in step 11 is a workaround, not a fix. It runs before paint, but it still runs after the server has already sent HTML down the wire, which means the very first bytes the browser receives don’t yet carry the correct class. For most apps that gap closes in a few milliseconds and nobody notices. For apps rendered fully on the server on every request, rather than statically generated once at build time, there’s a cleaner option: store the preference in a cookie instead of localStorage, since cookies travel with the request and the server can read them before it renders a single byte of HTML.
In Next.js, that means reading the cookie inside a Server Component and passing the resolved theme down as the initial class, rather than relying on next-themes’ client-side script at all:
// app/layout.tsx
import { cookies } from "next/headers"
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const theme = (await cookies()).get("theme")?.value ?? "system"
return (
{children}
)
}
Nuxt’s @nuxtjs/color-mode module supports the same pattern by setting storage: "cookie" in the colorMode config block, at which point the module reads the cookie during server rendering automatically and you don’t need to write the manual cookie-parsing logic the Next.js example above requires. This is a case where Vue’s module-based approach genuinely does less work for the developer than React’s more manual, more explicit style, even though both end up solving the identical problem.
Step 12: Sync With the OS Theme and Test Both Apps
The last step is making sure a user who hasn’t set an explicit preference still sees live updates when they change their OS theme, say by flipping their laptop into dark mode at 6 PM through an automatic schedule. Both next-themes and @nuxtjs/color-mode listen to prefers-color-scheme changes automatically when set to “system,” so if you followed steps 2 and 6 exactly, this already works. Verify it by opening your OS appearance settings while the app is open in the background and confirming the page updates without a manual refresh.
For the hand-rolled Vite + React version, add a media query listener inside the provider’s effect to replicate this behavior, since the plain context in step 9 only reads the preference once on mount:
useEffect(() => {
const mql = window.matchMedia("(prefers-color-scheme: dark)")
const handler = (e: MediaQueryListEvent) => {
if (!localStorage.getItem("theme")) setTheme(e.matches ? "dark" : "light")
}
mql.addEventListener("change", handler)
return () => mql.removeEventListener("change", handler)
}, [])
Test both apps in three states before calling this done: a fresh load with no stored preference (should match the OS), a fresh load with an explicit stored preference (should override the OS), and a live OS theme change while the tab is open with no explicit preference stored.
Migrating an Existing App Off Media-Query-Only Dark Mode
A lot of teams already shipped a first pass at dark mode years ago using nothing but @media (prefers-color-scheme: dark) in their CSS, with no toggle and no override. Migrating that setup to the class-based approach in this tutorial without breaking existing styles takes a specific order of operations, because doing it in the wrong sequence leaves users stuck in whichever mode their OS happens to be in during the rollout.
Start by auditing every @media (prefers-color-scheme: dark) block in the codebase and converting each one to the equivalent dark: utility or .dark-scoped selector, without deleting the media query yet. Ship that change first and verify visually that nothing regressed, since at this point both the old media query and the new class-based rule can coexist without conflict as long as the class is never actually applied. Only after that verification pass should you wire up next-themes or @nuxtjs/color-mode and remove the now-redundant media query rules. Skipping straight to removing the media queries before the class-based replacements are confirmed working is the single most common way this migration goes wrong in a real production app, because a missed selector means a section of the UI silently loses its dark styling entirely rather than just looking slightly off.
For teams running both a legacy Vue 2 section and a newer Vue 3 or Nuxt section during a gradual migration, keep the storage key identical across both (theme or whatever your team standardizes on) so a preference set in one part of the app carries over to the other instead of resetting every time a user crosses between the old and new code.
React vs Vue Dark Mode: Side-by-Side Comparison
Once both stacks are running, the practical differences come down to setup ceremony, bundle weight, and how much of the FOUC and hydration problem the tooling solves for you versus leaves on your plate.
| Factor | React (next-themes) | Vue (Nuxt + @nuxtjs/color-mode) |
|---|---|---|
| Setup style | Manual provider + suppressHydrationWarning | Module registration in config, auto-imported composable |
| FOUC prevention | Automatic, injected blocking script | Automatic, injected blocking script |
| Plain SPA (no meta-framework) | Hand-rolled context, about 25 lines | @vueuse/core useDark, about 3 lines |
| Weekly npm downloads | 18,102,551 (next-themes) | 9,924,968 (@vueuse/core) / 581,081 (@nuxtjs/color-mode) |
| Tailwind v4 wiring | @custom-variant dark, class=”dark” | @custom-variant dark, requires classSuffix: “” config |
| System preference sync | Built-in with enableSystem | Built-in with preference: “system” |
The download numbers tell an honest story. React’s ecosystem has converged hard on one library, so almost every React dark-mode question you’ll find online assumes next-themes. Vue’s numbers are split across a general-purpose composable library and a Nuxt-only module, which means the right answer for a Vue project genuinely depends on whether you’re running Nuxt or plain Vue plus Vite. There isn’t a single default answer the way there is in React. If you’re deciding between the two frameworks more broadly and state management is part of that decision, our React vs Vue state management comparison covers a related piece of that same architectural split.
Common Pitfalls When Building Dark Mode
- Forgetting suppressHydrationWarning on the html tag. Without it, every Next.js page with next-themes logs a hydration mismatch warning in the console on load, even though the app functions correctly. It’s cosmetic but it trains your team to ignore real hydration warnings too.
- Leaving classSuffix unset in @nuxtjs/color-mode. The module defaults to .dark-mode and .light-mode classes, not the .dark class Tailwind’s documentation examples assume. Your dark: utilities compile fine and simply never activate, with no error anywhere.
- Reading localStorage during server-side rendering. localStorage doesn’t exist on the server. Code that calls it directly in a component body, rather than inside useEffect or onMounted, crashes the SSR pass in both Next.js and Nuxt.
- Skipping the inline FOUC script in plain Vite apps. Since Vite SPAs have no server-rendering step to hide the flash behind, forgetting the blocking script here is instantly visible on every single page load, not just an edge case.
- Mixing prefers-color-scheme media queries with class-based Tailwind utilities. If part of your CSS uses @media (prefers-color-scheme: dark) directly and another part uses dark: classes, a user’s explicit light-mode override while their OS is set to dark will look broken, because the media-query CSS ignores the class entirely.
- Assuming next-themes works identically in the Pages Router. It works, but the provider placement and hydration guard patterns differ from the App Router examples that dominate current documentation and tutorials, this one included.
Troubleshooting Guide
- Theme flashes white on every reload despite next-themes being installed. Check that suppressHydrationWarning is on the html element specifically, not the body, and that ThemeProvider wraps the entire tree in the root layout, not a nested layout.
- dark: utilities have no effect in Tailwind v4. Confirm the @custom-variant dark line exists in the CSS file actually imported by your entry point, and that it comes after @import “tailwindcss”, not before.
- Nuxt color mode toggle changes state but the page doesn’t visually update. This is almost always the missing classSuffix: “” config from the pitfalls section above. Verify the class on html in DevTools matches what your CSS selector expects.
- useDark in a plain Vue app resets on every page navigation. Make sure useDark is called once at the app root, in main.ts or App.vue, not inside a route component that unmounts and remounts on navigation.
- Theme preference doesn’t persist across browser tabs opened at the same time. localStorage changes in one tab don’t automatically notify other open tabs. You need a storage event listener if cross-tab sync matters for your app, which none of the libraries here add by default.
- Next.js build fails with “useTheme must be used within a ThemeProvider.” Check for a component calling useTheme outside the App Router segment where ThemeProvider is mounted, commonly in a shared component imported into both authenticated and public layouts.
- System theme changes don’t update the app in real time. Confirm enableSystem is set on next-themes’ ThemeProvider or preference: “system” is set in the Nuxt color-mode config. Both default differently depending on version, so don’t assume it’s on.
- Dark mode class appears on html but colors still look wrong. Check for CSS specificity conflicts from a component library or global reset stylesheet loaded after your Tailwind output. The dark class is present but a later, more specific selector is overriding your dark: utility.
Advanced Tips: Multi-Theme Systems and View Transitions
Once light and dark work reliably, teams usually want more than two themes: a high-contrast mode, a brand-specific palette, or seasonal themes. Both next-themes and @nuxtjs/color-mode support this natively by accepting an array of theme names instead of a boolean, so the same infrastructure scales without a rewrite. In next-themes, pass themes={["light", "dark", "contrast"]} to the provider instead of relying on the default light and dark pair. In Nuxt’s color-mode module, set colorMode: { preference: "system", dataValue: "theme" } and manage the extra values through your own composable wrapping useColorMode.
The cleanest way to back a multi-theme setup is CSS custom properties scoped by data attribute rather than a growing pile of conditional Tailwind classes, since it keeps the number of theme-aware CSS rules constant no matter how many themes you eventually add:
/* Works identically whether the attribute is set by next-themes or @nuxtjs/color-mode */
:root { --bg: #ffffff; --fg: #111111; }
[data-theme="dark"] { --bg: #111111; --fg: #f5f5f5; }
[data-theme="contrast"] { --bg: #000000; --fg: #ffff00; }
body { background: var(--bg); color: var(--fg); }
Point next-themes at attribute="data-theme" instead of the default class strategy, and set Nuxt’s colorMode.attribute to the same value, and both frameworks drive this identical CSS layer with zero framework-specific styling logic. This is the pattern to reach for once you’re past two themes, since it decouples the number of themes from the number of style rules you have to maintain.
For a visual upgrade, wrap the toggle’s class change in the View Transitions API, supported in Chromium and Safari as of 2026, to animate the color swap instead of an instant cut. Both frameworks let you intercept the setter function, so the change is a few lines around the existing toggle handler rather than a new subsystem. Accessibility matters here too. A toggle button needs an accessible label and correct aria-pressed state regardless of which library drives it, something our React vs Vue accessibility setup guide covers in more depth if dark mode is one piece of a larger accessibility pass.
One more practical tip: store the theme preference in a cookie rather than localStorage if your app does full server-side rendering per request, as opposed to static generation, since cookies are readable by the server on the very first request. That eliminates even the brief FOUC window that a client-only blocking script has to fight against. Both next-themes and @nuxtjs/color-mode support cookie-based storage as a configuration option rather than the localStorage default.
Complete Working Project: Full Code Reference
Putting the pieces from steps 1 through 12 together, a production-ready React setup consists of four files: the ThemeProvider wrapper, the root layout with suppressHydrationWarning, the ThemeToggle component with its mounted guard, and the globals.css file with the custom-variant line. A production-ready Nuxt setup needs three: nuxt.config.ts with the module and classSuffix configured, the toggle component using the auto-imported composable, and the Tailwind CSS entry file. Neither setup needs a state management library, a context beyond what’s shown, or any server infrastructure. This is intentionally the smallest correct implementation for each framework, not a simplified version that skips edge cases.
To verify the finished build, run both apps locally, open DevTools, and inspect the html element’s class list while toggling. You should see the class appear and disappear instantly with no flash on reload, and the Tailwind dark: utilities should apply within the same paint. Run npm run build && npm run start for the Next.js app to confirm the production build, not just dev mode, also avoids the hydration warning, since some hydration issues only appear once React’s dev-mode warnings are stripped out.
# Expected output after npm run build (Next.js)
Route (app) Size First Load JS
┌ ○ / 1.2 kB 92.4 kB
└ ○ /_not-found 871 B 88.1 kB
○ (Static) prerendered as static content
# No hydration warnings in the browser console on load
If you’re setting up TypeScript alongside this build and want the typed version of both toggle components with proper prop typing, our React vs Vue TypeScript setup guide is a natural next step that pairs directly with the code in this article.
Frequently Asked Questions
Does next-themes work outside of Next.js, in a plain React app?
It works technically, since it doesn’t depend on any Next.js-specific API, but its main value, solving server-render hydration mismatches, doesn’t apply to a client-only Vite app. For plain React, the hand-rolled context in step 9 is lighter and just as correct.
Why does Nuxt need classSuffix set to an empty string?
@nuxtjs/color-mode defaults to appending “-mode” to the class name for historical reasons, producing .dark-mode instead of .dark. Setting classSuffix to an empty string makes it match the convention Tailwind’s own documentation and most component libraries assume.
Can I use @vueuse/core’s useDark inside a Nuxt app instead of @nuxtjs/color-mode?
Yes, and some teams prefer it for consistency if they’re already using VueUse elsewhere in the app. You lose the module’s automatic FOUC-prevention script injection though, so you’d need to add the inline blocking script from step 11 manually.
Is Tailwind CSS required for any of this to work?
No. Both next-themes and @nuxtjs/color-mode just toggle a class or data attribute on the html element. What CSS framework reads that class is entirely up to you. Plain CSS custom properties scoped with a .dark selector work identically.
Why is there a visible flash even with next-themes installed correctly?
Check that the ThemeProvider wraps the very top of your component tree in the root layout file, not a nested layout further down the route tree. next-themes injects its blocking script relative to where the provider mounts, so placing it too deep leaves earlier-rendered markup unprotected.
Does dark mode affect Core Web Vitals or page performance?
The libraries themselves add negligible weight. next-themes is under 2 KB gzipped, and @vueuse/core’s useDark composable is a small fraction of the full VueUse bundle if you import it individually rather than the whole package. The bigger performance risk is the FOUC-prevention script blocking render by a few milliseconds, an intentional, tiny trade-off for correctness.
How do I test dark mode in automated tests without a real browser theme setting?
Mock window.matchMedia in your test setup file to return a controlled value for prefers-color-scheme, then assert on the presence of the dark class after rendering. Both Vitest and Jest support this through a simple mock implementation added to your global test setup.
Should I default new users to system preference or light mode?
Default to system preference. Overriding a user’s OS-level accessibility and visual comfort settings with an arbitrary default is a common source of complaints, and both libraries covered here make “system” the sensible default for exactly that reason.
What’s the difference between next-themes’ attribute=”class” and attribute=”data-theme” options?
Both do the same job of marking the active theme on the html element, but a class only really supports a binary light and dark switch cleanly, since Tailwind’s dark: variant is built around one class name. A data attribute scales more naturally to three or more themes, as shown in the multi-theme CSS example above, without needing a separate custom variant per additional theme.
Do I need to install both @vueuse/core and @nuxtjs/color-mode in a Nuxt project?
No, and doing so usually causes conflicts rather than added functionality, since both would try to manage the same html class independently. Pick @nuxtjs/color-mode for Nuxt-specific projects because of its built-in FOUC prevention and cookie storage support, and reach for @vueuse/core’s useDark only in plain Vue apps that don’t have Nuxt’s module system available.





