Vue and Tailwind: Sensible Patterns Instead of Class Soup
AI generated
<v/>
{ }
Vue.js · Tailwind CSS · cva · Design Tokens · Components
Vue and Tailwind
Sensible Patterns Instead of Class Soup

A button with 30 Tailwind classes in the template is not a Tailwind problem, it is an architecture problem. Vue and Tailwind can be combined so that class logic lives in computed properties, variants are managed through cva and templates stay readable, without sacrificing the expressiveness of Tailwind.

14 min read computed · cva · @apply · CSS variables · Tailwind v4 Vue 3 · Tailwind CSS v4 · TypeScript

1. Why Vue and Tailwind Often Turn Chaotic

The combination of Vue and Tailwind leads to a specific antipattern in many projects: ever-longer :class attributes that mix base classes, state classes and variant classes into a single string expression. A button that exists in different sizes, colors and states quickly accumulates 25 to 40 classes. The template becomes the primary place for styling logic, which means a designer or another developer who wants to reduce or change the set of classes must dig deep into the component logic to understand which classes are conditional and which are always active.

The problem does not lie in Tailwind, but in the missing separation between styling logic and template structure. Vue provides computed properties, composables and a component architecture, all the tools needed to encapsulate class logic and make it reusable. The solution is not to move away from Tailwind, quite the opposite. The utility-first philosophy only unfolds its full potential once the class logic lives in encapsulated abstractions and the template only declaratively describes which variant and which state is active, without knowing the concrete classes.

2. Computed Properties for Class Logic

The first step toward clean Vue and Tailwind code is moving class logic out of the template and into computed properties. Instead of :class="[isActive ? 'bg-green-500 text-white' : 'bg-white text-slate-800', isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:bg-green-600', size === 'lg' ? 'px-6 py-3 text-lg' : 'px-4 py-2 text-sm']" in the template, there is only :class="buttonClasses". The entire logic lives in a computed property that has clearly named keys for each state, is easy to read and can be tested in isolation.

Vue's computed object syntax { 'bg-green-500': isActive, 'opacity-50': isDisabled } combines cleanly with string arrays. For complex variants it is worth splitting into several small computeds: baseClasses for always active classes, variantClasses for size and color variants and stateClasses for hover, focus and disabled states. That keeps the logic modular and makes it easier to add new variants without breaking the existing system.


// BaseButton.vue: computed classes instead of inline class logic
<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'secondary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  loading?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  size: 'md',
  disabled: false,
  loading: false,
})

// Base classes: always active
const baseClasses = 'inline-flex items-center justify-center font-semibold rounded-xl transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'

// Size variant map
const sizeClasses: Record<NonNullable<Props['size']>, string> = {
  sm: 'px-3 py-1.5 text-sm gap-1.5',
  md: 'px-4 py-2 text-sm gap-2',
  lg: 'px-6 py-3 text-base gap-2.5',
}

// Color variant map
const variantClasses: Record<NonNullable<Props['variant']>, string> = {
  primary: 'bg-green-600 text-white hover:bg-green-700 focus:ring-green-500',
  secondary: 'bg-slate-100 text-slate-900 hover:bg-slate-200 focus:ring-slate-400',
  ghost: 'bg-transparent text-slate-700 hover:bg-slate-100 focus:ring-slate-400',
}

// Composed class string via computed
const buttonClasses = computed(() => [
  baseClasses,
  sizeClasses[props.size],
  variantClasses[props.variant],
  { 'opacity-50 cursor-not-allowed pointer-events-none': props.disabled || props.loading },
])
</script>
// Template: <button :class="buttonClasses" :disabled="disabled || loading">

3. class-variance-authority: Managing Variants Type-Safely

Class-variance-authority (cva) is a small library that formalizes the pattern of variant class logic and makes it type-safe. Instead of manually built maps and conditionals, cva() defines base classes, variants and their default values in a declarative structure. The result is a function that takes a props object and returns the composed class string. The decisive advantage: TypeScript automatically infers the allowed values for each variant from the cva definition. Invalid values become a compile time error, not a runtime surprise.

cva also supports compound variants, classes that are only active when several variants simultaneously hold a specific value. That enables, for example, a class that is only active on a primary button in the lg size, without separate if blocks. Combining Vue and Tailwind with cva makes it possible to build component libraries with consistent variants that are type-safe, documented and extensible when new variants are added, without changing existing code.

4. Component Abstraction: When @apply Makes Sense

The Tailwind directive @apply is controversial, but its use case is clear: it makes sense when a set of utilities forms a semantically meaningful abstraction that occurs across multiple templates but is too small for its own Vue component. The textbook example is typographic styles: a .prose-heading class set made of text-2xl font-bold leading-tight tracking-tight text-slate-900, used in ten different templates for <h2> headings, is a good candidate for @apply. A button class, on the other hand, that differs depending on context and has props-based variants belongs in a component.

The rule of thumb: @apply for static, non-variant utility combinations used on plain HTML elements (not components). Vue components for everything that has props, slots, events or variants. That prevents @apply from becoming a substitute for CSS-in-CSS and Tailwind classes disappearing into nested @apply rules, which would undermine the CSS-first principle of Tailwind v4. Important: @apply with Tailwind v4 only works in CSS files, not in <style> blocks of Vue SFCs without explicit PostCSS configuration.

5. Tailwind v4: CSS-first and @theme

Tailwind v4 brings a fundamental change with the CSS-first approach: the configuration no longer happens in tailwind.config.js, but directly in the CSS file with @import "tailwindcss" and @theme blocks. For Vue and Tailwind projects this has several advantages: no JavaScript configuration boilerplate, direct CSS variable access to theme values and full integration with the design token system. A @theme block defines custom properties that Tailwind uses as the basis for utility classes while simultaneously exposing them as CSS variables throughout the document.

For Vue and Tailwind v4 projects that means: theme values are not only accessible via theme('colors.green.600') in JavaScript, but directly as var(--color-green-600) in CSS and also in inline styles of Vue templates. The Vite integration of Tailwind v4 via @tailwindcss/vite replaces the previous PostCSS plugin approach and processes Tailwind classes directly inside the Vite build process, faster and without a separate PostCSS configuration file.


/* main.css: Tailwind v4 CSS-first configuration */
@import "tailwindcss";

/* Custom theme tokens: available as CSS vars AND Tailwind utilities */
@theme {
  --color-brand-50: oklch(97% 0.02 145);
  --color-brand-500: oklch(60% 0.18 145);
  --color-brand-600: oklch(52% 0.20 145);
  --color-brand-700: oklch(44% 0.18 145);

  --font-sans: 'Inter Variable', ui-sans-serif, system-ui;
  --radius-xl: 0.875rem;
  --radius-2xl: 1.25rem;

  --spacing-18: 4.5rem;
  --spacing-22: 5.5rem;
}

/* Static abstractions with @apply: NOT for variant-based components */
@layer components {
  .heading-xl {
    @apply text-4xl font-bold leading-tight tracking-tight text-slate-900;
  }
  .heading-lg {
    @apply text-2xl font-bold leading-snug text-slate-900;
  }
  .body-lg {
    @apply text-lg leading-relaxed text-slate-700;
  }
}

/* vite.config.ts: Tailwind v4 Vite plugin */
// import tailwindcss from '@tailwindcss/vite'
// plugins: [vue(), tailwindcss()]
// No postcss.config.js needed

6. Design Tokens with CSS Variables

Design tokens are named design decisions, colors, spacing, fonts, radii, that serve as the single source of truth and are accessible in every part of the system (Vue components, CSS, inline styles). With Tailwind v4 and CSS variables these concepts converge: @theme values are simultaneously Tailwind utilities and CSS custom properties. A color defined as --color-brand-600 automatically produces utilities such as bg-brand-600, text-brand-600, border-brand-600 and is usable via var(--color-brand-600) in any CSS context.

In Vue components, design tokens can be elegantly used for dynamic inline styles not covered by Tailwind utilities. :style="{ '--progress': progressPercent + '%' }" sets a CSS variable that is then referenced in a @keyframes animation or a clip-path rule. That separates the dynamic value (from Vue state) from the presentation logic (in CSS), without a separate JavaScript watcher having to manually update DOM properties. This combination of Vue reactivity and CSS custom properties is one of the most elegant patterns in modern Vue and Tailwind projects.

7. Dark Mode in Vue with Tailwind

Implementing dark mode with Tailwind in a Vue application requires deciding between the media strategy (follows the user's system setting) and the class strategy (manual toggling via a CSS class on the html element). For applications with an explicit theme switcher, the class strategy is the right choice: a composable manages the current theme state in a Pinia store or localStorage, and a watcher adds or removes the dark class on document.documentElement.

Tailwind classes with the dark: modifier behave in Vue components like any other Tailwind class, they work in computed properties, cva definitions and @apply rules without changes. Important: Tailwind v4 supports @theme dark for overriding design token values for dark mode directly in CSS, without setting the dark: modifier on every single element. That enables coherent dark mode theming at the token level instead of the component level.

8. Responsive Design Patterns for Components

Responsive Vue components with Tailwind follow the mobile-first principle: base classes without a breakpoint prefix are for mobile devices, sm:, md:, lg: and xl: override these for larger viewports. In computed properties this pattern can be applied directly: Tailwind responsive classes are normal strings and require no special handling in Vue. The challenge arises when responsive behavior is props-dependent: a grid component that takes a :cols="3" prop and generates grid-cols-3 from it cannot simply assemble dynamic classes, because Tailwind only includes statically analyzed classes in the build.

The solution: store complete class strings in a lookup map instead of assembling them dynamically. const colsMap = { 2: 'grid-cols-2', 3: 'grid-cols-3', 4: 'grid-cols-4' } instead of `grid-cols-${props.cols}`. Tailwind's static analyzer cannot resolve grid-cols-${n} and does not include these classes in the build. Anyone who relies on dynamic classes must list them explicitly in the safelist configuration, in Tailwind v4 via @source in the CSS file or via the safelist option in the Vite plugin.

9. Tailwind Patterns Compared

Choosing the right Vue and Tailwind pattern depends on the complexity of the component and the number of variants. The following table helps with the decision:

Situation Bad Pattern Recommended Pattern Reason
Button variants 30+ classes in the template cva in the component Type-safe, extensible
Dynamic classes `grid-cols-${n}` Lookup map with full classes Tailwind analyzer recognizes classes
Typography Same 8 classes copied 20x @apply in layer components DRY, static, no variants
Theme colors Hardcoded HEX in inline style CSS variables via @theme Consistent, dark mode capable
State classes Ternary expressions in the template Computed property with object syntax Readable, testable, maintainable

The main rule for Vue and Tailwind: templates describe structure and state, computed properties encapsulate class logic, cva manages variants, @apply abstracts static utility combinations and design tokens live in @theme CSS variables. This split keeps every layer focused and avoids the class soup antipattern without limiting the expressiveness of Tailwind.

Mironsoft

Vue.js frontend development, Tailwind CSS and component libraries

Want to structure Vue and Tailwind cleanly?

We build Vue component libraries with Tailwind and cva, type-safe, maintainable and with a clear separation between structure and styling logic.

Component audit

Analyzing existing components for class soup, duplicated logic and missing abstraction

Design system

Design token system with Tailwind v4 and CSS variables, consistent, dark mode capable, extensible

cva migration

Migrating existing variant logic to cva, with TypeScript type safety and automatic prop inference

10. Summary

Clean Vue and Tailwind code emerges from consistently separating responsibilities: templates describe structure, computed properties encapsulate class logic, cva manages variants type-safely, @apply abstracts static utility combinations without variants, and design tokens live in @theme CSS variables. That prevents the class soup antipattern without limiting the expressiveness and flexibility of Tailwind.

Tailwind v4 with the CSS-first approach and @theme blocks makes the design token system more coherent: custom properties are simultaneously Tailwind utilities and CSS variables usable in inline styles of Vue components. Anyone who consistently applies these patterns builds UI component libraries that are extensible for new variants, remain stable through refactorings and are readable for every developer on the team, without needing to know every single Tailwind utility.

Vue and Tailwind Patterns: The Essentials at a Glance

Encapsulate class logic

computed properties instead of inline :class expressions. Base, variant and state classes in separate, testable computeds.

cva for variants

class-variance-authority delivers TypeScript type safety for component variants, invalid prop values become compile errors, not runtime bugs.

@apply for static content

Only for static, non-variant utility combinations. Not for components with props and events, those belong in Vue components.

Tailwind v4 @theme

Design tokens as CSS variables in @theme blocks, simultaneously Tailwind utilities and var() values in inline styles and CSS.

11. FAQ: Vue and Tailwind, Patterns and Best Practices

1Why not leave classes in the template?
Long class attributes mix logic and structure, are hard to read and make refactorings expensive. computed properties encapsulate class logic testably and keep templates focused.
2What is cva and when do I need it?
class-variance-authority for type-safe variants, when size x color x state add up to multiple dimensions. TypeScript infers allowed prop values automatically.
3When to use @apply?
Only for static, non-variant utility groups on plain HTML elements, for example typography sets. Not for components with props and variants.
4Dynamic Tailwind classes in Vue?
Tailwind analyzes statically, `grid-cols-${n}` is not recognized. Use lookup maps with complete class strings: { 2: 'grid-cols-2', 3: 'grid-cols-3' }.
5What is new in Tailwind v4?
CSS-first with @import 'tailwindcss' and @theme instead of tailwind.config.js. The Vite plugin replaces PostCSS. Tokens are simultaneously CSS variables and utilities.
6Dark mode with Tailwind in Vue?
Class strategy: set the dark class on the html element via a Vue composable. Tailwind v4 enables @theme dark for token-level dark mode.
7Avoiding missing classes in the build?
Use complete class strings instead of dynamically assembled ones. List missing classes explicitly via @source in CSS or the safelist in the Vite plugin.
8cva with TypeScript in Vue?
Fully typed. type ButtonProps = VariantProps<typeof buttonVariants> derives props interfaces directly. Invalid values become compile errors.
9CSS variables vs. Tailwind tokens?
Same thing in Tailwind v4: @theme values produce CSS custom properties and Tailwind utilities simultaneously. Define once, use everywhere.
10Testing computed classes?
computed properties are pure functions, directly testable with Vitest without DOM rendering: expect(buttonClasses.value).toContain('bg-green-600').