Vue & Nuxt
Set up Tailwind v4 in Vite + Vue and Nuxt, use @reference for @apply inside SFC style blocks, bind classes safely, and wire up dark mode and transitions.
Vue and Nuxt both build with Vite, so both use @tailwindcss/vite. The interesting parts are Vue-specific: @apply inside a single-file component's <style> block needs @reference, and :class bindings hit the same complete-class-name rule as every other framework.
Vite + Vue#
- Install.
- Register the plugin.
- Import Tailwind in your CSS.
- Import that CSS from your entry module.
npm install tailwindcss @tailwindcss/viteimport { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
vue(),
tailwindcss(),
],
})@import "tailwindcss";import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
createApp(App).mount('#app')Nuxt#
Nuxt owns the Vite config, so the plugin goes inside vite.plugins in nuxt.config.ts, and the stylesheet is registered through Nuxt's css array rather than an import in an entry file.
npm install tailwindcss @tailwindcss/vite@import "tailwindcss";import tailwindcss from "@tailwindcss/vite";
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
css: ['~/assets/css/main.css'],
vite: {
plugins: [
tailwindcss(),
],
},
});Both halves are required. vite.plugins compiles the CSS; css: [...] is what loads it into every page.
Using Tailwind in SFCs#
Utilities go straight in the template. Nothing about a single-file component changes how they work.
<script setup lang="ts">
defineProps<{ name: string; price: string }>()
</script>
<template>
<div class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-gray-800 dark:ring-white/10">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ name }}</h3>
<p class="mt-2 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">{{ price }}</p>
</div>
</template>Source detection walks the project from the directory containing your CSS file, so .vue files anywhere under the project root are scanned automatically — components, layouts, pages and app.vue alike.
@apply in a <style> block needs @reference#
This is the one thing that surprises everyone coming from v3.
A <style> block in an SFC is compiled as its own stylesheet. It has no idea what @import "tailwindcss" did in main.css, so @apply, @variant and --theme() have no theme to read from and the build fails.
Point the block at your main stylesheet with @reference:
<template>
<h1>Hello world!</h1>
</template>
<style scoped>
@reference "../assets/css/main.css";
h1 {
@apply text-2xl font-bold text-red-500;
}
</style>If your theme has no customisations at all, you can reference the package directly:
<style scoped>
@reference "tailwindcss";
h1 {
@apply text-2xl font-bold text-red-500;
}
</style>Referencing does not duplicate output#
@reference imports your stylesheet for reference only. Tailwind reads the theme variables, utility definitions and custom variants it needs to expand @apply, then emits nothing from the referenced file itself. Preflight, your @theme block and every utility class stay in the single stylesheet where they were declared.
That is exactly why @reference exists instead of @import. Writing @import "tailwindcss" inside a <style> block would inline the entire framework into every component that did it — preflight repeated once per component, and a bundle that grows with your component count.
The path is resolved relative to the component file, so it changes as you move components between directories. In a Nuxt project with the CSS at app/assets/css/main.css, a component in app/components/ references ../assets/css/main.css.
Binding classes#
:class accepts the same object, array and string forms as always. The rule that matters is Tailwind's, not Vue's: class names must appear in your source as complete, unbroken strings, because the scanner reads files as plain text and never evaluates your expressions.
Object and array syntax#
<template>
<button
class="rounded-lg px-4 py-2 font-medium transition-colors"
:class="{
'bg-indigo-600 text-white hover:bg-indigo-500': variant === 'primary',
'bg-gray-100 text-gray-900 hover:bg-gray-200': variant === 'secondary',
'cursor-not-allowed opacity-50': disabled,
}"
>
<slot />
</button>
</template>Every key is a complete class string. Vue merges class and :class for you, so keep the constant utilities in the static class attribute — they stay readable and IntelliSense treats them normally.
Dynamic classes and the source-detection rule#
Map props to whole class names in a lookup object:
<script setup lang="ts">
const props = withDefaults(
defineProps<{ tone?: 'info' | 'success' | 'danger' }>(),
{ tone: 'info' },
)
const TONES = {
info: 'bg-sky-50 text-sky-700 inset-ring-sky-600/20',
success: 'bg-emerald-50 text-emerald-700 inset-ring-emerald-600/20',
danger: 'bg-red-50 text-red-700 inset-ring-red-600/20',
}
</script>
<template>
<span
class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium inset-ring"
:class="TONES[props.tone]"
>
<slot />
</span>
</template>A computed works equally well and keeps the template thin:
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ tone: keyof typeof TONES }>()
const toneClass = computed(() => TONES[props.tone])
</script>The scanner is happy either way — what it needs is the literal strings in TONES, and they are right there in the <script> block of a file it already reads.
Scoped styles vs utilities#
<style scoped> adds a data-v-* attribute to every element and scopes selectors to it. Useful for hand-written CSS; unnecessary for utilities, which are already scoped by being written on the element itself.
| Situation | Use |
|---|---|
| Styling elements in your own template | Utility classes |
| A state that repeats across many elements | Utility classes with a variant |
| A pseudo-element, or a selector you cannot attach a class to | <style scoped> |
Styling markup you do not control — v-html, a third-party widget | <style> with :deep() |
| A design token used in a computed value | var(--color-*) in <style> |
Two behaviours are worth knowing:
- Scoped styles do not reach child components' internals. Use
:deep(.selector)to cross that boundary, or pass classes down through props. - Utilities are unaffected by scoping. A
p-4on a child element behaves the same whether the parent's<style>is scoped or not.
Dark mode#
v4 has no darkMode option. Define the variant in CSS, then choose something to toggle the class.
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));Vue — a composable#
import { ref, watchEffect } from 'vue'
const theme = ref(localStorage.theme ?? 'system')
export function useDark() {
watchEffect(() => {
const dark =
theme.value === 'dark' ||
(theme.value === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.toggle('dark', dark)
if (theme.value === 'system') localStorage.removeItem('theme')
else localStorage.theme = theme.value
})
return { theme }
}The class still has to be applied before first paint. In a client-rendered Vue app, put a blocking script in index.html above the module script:
<script>
document.documentElement.classList.toggle(
"dark",
localStorage.theme === "dark" ||
(!("theme" in localStorage) && window.matchMedia("(prefers-color-scheme: dark)").matches),
);
</script>Nuxt — @nuxtjs/color-mode#
Nuxt renders on the server, so a naive client-side toggle flashes. The @nuxtjs/color-mode module inlines the detection script for you and adds a .dark or .light class to <html>.
npx nuxt module add color-modeimport tailwindcss from "@tailwindcss/vite";
export default defineNuxtConfig({
modules: ['@nuxtjs/color-mode'],
css: ['~/assets/css/main.css'],
vite: {
plugins: [tailwindcss()],
},
});Its classSuffix defaults to an empty string, so the class is exactly dark — which is what the @custom-variant above matches. If you inherited a project that sets classSuffix: '-mode', either clear it or write the variant against .dark-mode.
<script setup lang="ts">
const colorMode = useColorMode()
</script>
<template>
<button
class="rounded-lg p-2 text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
@click="colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'"
>
Toggle theme
</button>
</template>Transitions#
Vue's <Transition> applies classes at each stage of an enter or leave. By default they are derived from the name prop — v-enter-from, v-enter-active and so on — which means writing CSS. The six *-class props let you supply Tailwind utilities instead and skip the stylesheet entirely.
| Prop | Applied |
|---|---|
enter-from-class | One frame before entering |
enter-active-class | For the whole enter — put transition and duration-* here |
enter-to-class | The entered state |
leave-from-class | The state at the start of leaving |
leave-active-class | For the whole leave |
leave-to-class | The final state before removal |
<template>
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-2"
>
<p v-if="show" class="rounded-lg bg-indigo-600 px-4 py-2 text-white">Saved</p>
</Transition>
</template>The pattern is always the same: *-active-class carries the transition, duration-* and ease-* utilities; the from/to pairs carry the values being animated.
<TransitionGroup> takes the same props, plus move-class for the elements that shift when the list reorders:
<template>
<TransitionGroup
tag="ul"
class="space-y-2"
move-class="transition duration-300"
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-x-2"
leave-active-class="absolute transition duration-200 ease-in"
leave-to-class="opacity-0"
>
<li v-for="item in items" :key="item.id" class="rounded-lg bg-gray-100 p-3">
{{ item.label }}
</li>
</TransitionGroup>
</template>leave-active-class includes absolute so a leaving item is taken out of flow and the remaining items can animate into their new positions.
For the underlying utilities and reduced-motion handling, see Transitions.
Common mistakes#
@applyin a<style>block without@reference. Each SFC style block compiles as its own stylesheet with no theme in scope. Add@reference "…/main.css";at the top of the block.- Using
@import "tailwindcss"inside a<style>block. That inlines the whole framework into every component that does it.@referenceis the directive that reads the theme without emitting anything. - Getting the
@referencepath wrong. It resolves relative to the component file, not the project root, so it differs betweenapp/components/andapp/pages/. - Registering the Vite plugin in Nuxt but forgetting the
cssarray. The stylesheet compiles and is never loaded. - Building class names in a binding. `
:class="bg-${color}-500"` produces nothing. Use a lookup object keyed by prop value. - Installing a v3-era Tailwind Nuxt module alongside
@tailwindcss/vite. It injects@tailwinddirectives and atailwind.config.jsthat v4 does not read. - Expecting
<style scoped>to reach into a child component. Scoping stops at the component boundary; use:deep()or pass classes as props.
Related topics#
- Installation — every install method side by side
- React & Next.js — the same patterns in JSX
- Functions & Directives —
@reference,@apply,@source - Dark Mode — the class strategy and the three-way toggle
- Transitions — durations, easing and reduced motion