Type-Safe Design System in Vue 3: Eliminating Class Conflicts with CVA and tailwind-merge

In large-scale projects, managing styles using Tailwind CSS utility classes within the class attribute results in complex string concatenations that are difficult to maintain. As the number of a component’s state combinations increases (visual variants, sizes, loading or disabled states), the logic for calculating classes within the template becomes more complex. Furthermore, the lack of native typing for style options prevents the compiler from detecting invalid values during the development phase.
Translated with DeepL.com (free version)

In this article, we’ll explore how to build fully type-safe Vue 3 components, where the Type-Safe, compiler prevents us from passing incorrect props, by combining Tailwind CSS, CVA (Class Variance Authority) and tailwind-merge.

1. Support Libraries

Deterministic class management requires three main tools:

  • cva (Class Variance Authority): Allows you to declare component variants using a JavaScript object, generating classes in a deterministic manner.
  • clsx: Manages the construction of class strings based on Boolean values or expressions.
  • tailwind-mergeIt intelligently resolves conflicts between Tailwind classes, ensuring that rules are overridden correctly (e.g., if you switch topx-4 a component that internally haspx-2, tailwind-merge[a certain class], it understands that px-4 should take precedence without relying on the order in the CSS).

Installing dependencies:

				
					bash
npm install class-variance-authority clsx tailwind-merge
				
			

2. The pattern cn(Class Name Utility)

Let’s create a centralized utility to combine the conditional logic of clsx with the deduplication and conflict resolution mechanism of tailwind-merge.

File src/utils/cn.ts:

				
					import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Unisce le classi CSS in modo condizionale e risolve i conflitti di Tailwind CSS.
 * @param inputs Array di classi CSS, oggetti condizionali o undefined
 * @returns Stringa di classi CSS unita e deduplicata
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}
				
			

3. Define variants using CVA

Tailwind class definitions are separated from the Vue component and placed in a dedicated configuration file, which replaces the ternary operators within the templates. This approach also allows for automatic type extraction for TypeScript.

File src/components/ui/button/buttonVariants.ts:

				
					import { cva, type VariantProps } from "class-variance-authority";

export const buttonVariants = cva(
  // 1. Classi base: sempre applicate
  "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:pointer-events-none disabled:opacity-50",
  {
    // 2. Varianti del componente
    variants: {
      variant: {
        primary: "bg-brand-600 text-white hover:bg-brand-700 shadow-sm",
        secondary: "bg-slate-100 text-slate-900 hover:bg-slate-200",
        outline:
          "border border-slate-300 bg-transparent hover:bg-slate-100 text-slate-700",
        danger: "bg-red-600 text-white hover:bg-red-700",
        ghost: "hover:bg-slate-100 hover:text-slate-900 text-slate-600"
      },
      size: {
        sm: "h-8 px-3 text-xs",
        md: "h-10 px-4 py-2",
        lg: "h-12 px-8 text-base"
      }
    },
    // 3. Fallback di default se la prop non viene passata
    defaultVariants: {
      variant: "primary",
      size: "md"
    }
  }
);
// Estraiamo le interfacce TypeScript direttamente dalla dichiarazione CVA!
export type ButtonVariants = VariantProps;
				
			

Exporting the type ButtonVariants extracts a union type derived directly from the keys defined in the object variants. This way, TypeScript knows that variant can only be “primary" | "secondary" | "outline" | "danger" | "ghost"” If you try to pass it to the component variant="success", the build will fail.

4. The Vue Component: <BaseButton>

Now let’s wrap everything into a Vue component that imports the types and the class calculation function to define the props interface. Thanks to the features introduced in Vue 3.3+ (which allow you to use externally imported types inside defineProps), the implementation is very clean.

File src/components/ui/button/BaseButton.vue:

				
					
import { computed, type Component } from 'vue'
import { cn } from '@/utils/cn'
import { buttonVariants, type ButtonVariants } from './buttonVariants'

// Definiamo le prop del nostro componente tipizzando le varianti
interface ButtonProps {
  variant?: ButtonVariants['variant']
  size?: ButtonVariants['size']
  class?: string // Permette di aggiungere classi extra dall'esterno
  as?: string | Component // Pattern polimorfico
}

const props = withDefaults(defineProps(), {
  variant: 'primary',
  size: 'md',
  as: 'button'
})

// Calcoliamo le classi combinando CVA e eventuali classi esterne iniettate
const computedClass = computed(() =&gt; {
  return cn(
    buttonVariants({ variant: props.variant, size: props.size }),
    props.class
  )
})



  
    <!-- Slot per icone pre-testo -->
    

    <!-- Contenuto principale -->
    

    <!-- Slot per icone post-testo -->
    
  

				
			

5. Developer Experience

Integration with TypeScript ensures type checking at compile time and supports auto-completion in the development environment.

				
					
import BaseButton from "@/components/ui/button/BaseButton.vue";



  <div class="flex gap-4">
    <!-- ✅ IntelliSense suggerirà le opzioni per variant e size -->
    
      Salva modifiche
    

    <!-- ❌ ERRORE TypeScript: Type '"super-large"' is not assignable to type '"sm" | "md" | "lg" | undefined'. -->
    
      Elimina
    

    <!-- Overriding sicuro grazie a tailwind-merge -->
    <!-- Pur avendo p-4 di default nel 'md', pt-8 vincerà senza conflitti CSS -->
    
      Bottone Custom
    
  </div>

				
			

Conclusions

Externalizing style rules using Class Variance Authority allows you to separate the definition of a component’s HTML structure from its visual configuration. Defining internal APIs makes development faster, more predictable, and error-proof. This model ensures three key properties:

  • Strict typing: rilevamento degli errori durante l’assegnazione delle props relative allo stile.
  • Modularity: a clear separation between the Vue template syntax and the logic of the CSS variants.
  • Deterministic class resolution: preventing CSS conflicts when overriding props through the integration of tailwind-merge.

Latest articles

Dicom Sotto Attacco

DICOM Under Attack: When Medical Images Become a Vector for Attacks

Say goodbye to server management: here is how we migrated from a single NixOS VM to AWS Fargate managed services. Discover the benefits of building Docker containers directly with Nix and how to simplify deployment with Infrastructure as Code.

NixOS to Serverless: Moving from VM to AWS Fargate

industrializzare l’AI nel medical imaging

Beyond the Algorithm: How to Industrialize AI in Medical Imaging

contact area

For information, projects, ideas, write to us