Design System Type-Safe in Vue 3: eliminare i conflitti di classe con CVA e tailwind-merge

Nei progetti di grandi dimensioni, la gestione dello stile tramite classi utility di Tailwind CSS all’interno dell’attributo class genera concatenazioni di stringhe complesse e difficili da manutenere. Con l’aumentare delle combinazioni di stato di un componente (varianti visive, dimensioni, stati di caricamento o disabilitazione), la logica di calcolo delle classi all’interno del template aumenta di complessità. Inoltre, la mancanza di tipizzazione nativa per le opzioni di stile impedisce al compilatore di rilevare valori non validi durante la fase di sviluppo.

In questo articolo vedremo come costruire componenti Vue 3 totalmente Type-Safe, dove il compilatore TypeScript ci impedisce di passare props errate, combinando Tailwind CSS, CVA (Class Variance Authority) e tailwind-merge.

1. Librerie di supporto

La gestione deterministica delle classi richiede tre strumenti principali:

  • cva (Class Variance Authority): permette di dichiarare le varianti del componente tramite un oggetto JavaScript, generando classi in modo deterministico.
  • clsx: gestisce la costruzione di stringhe di classi in base a valori booleani o espressioni.
  • tailwind-merge: Risolve i conflitti tra classi di Tailwind in modo intelligente, garantendo la sovrascrittura corretta delle regole (es. se passi px-4 a un componente che internamente ha px-2, tailwind-merge capisce che px-4 deve vincere senza affidarsi all’ordine nel CSS).

Installazione delle dipendenze:

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

2. Il pattern cn (Class Name Utility)

Creiamo un’utility centralizzata per combinare la logica condizionale di clsx con il meccanismo di deduplicazione e risoluzione dei conflitti di 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. Definire le varianti con CVA

La definizione delle classi Tailwind viene isolata dal componente Vue e collocata in un file di configurazione dedicato, che prende il posto degli operatori ternari all’interno dei template. Questo approccio consente anche di estrarre automaticamente i tipi per 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<typeof buttonVariants>;
				
			

L’esportazione del tipo ButtonVariants estrae un tipo unione derivato direttamente dalle chiavi definite nell’oggetto variants. In questo modo TypeScript sa che variant può essere solo “primary" | "secondary" | "outline" | "danger" | "ghost". Se si cerca di passare al componente variant="success", la build fallirà.

4. Il Componente Vue: <BaseButton>

Ora integriamo tutto in un componente Vue che importa i tipi e la funzione di calcolo delle classi per definire l’interfaccia delle props. Grazie alle feature introdotte in Vue 3.3+ (che permette di usare tipi importati esternamente dentro defineProps), l’implementazione è molto pulita.

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

				
					<script setup lang="ts">
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<ButtonProps>(), {
  variant: 'primary',
  size: 'md',
  as: 'button'
})

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

<template>
  <component
    :is="as"
    :class="computedClass"
  >
    <!-- Slot per icone pre-testo -->
    <slot name="left-icon" />

    <!-- Contenuto principale -->
    <slot />

    <!-- Slot per icone post-testo -->
    <slot name="right-icon" />
  </component>
</template>
				
			

5. Developer Experience

L’integrazione con TypeScript garantisce il controllo dei tipi in fase di compilazione e il supporto dell’autocompletamento nell’ambiente di sviluppo.

				
					<script setup lang="ts">
import BaseButton from "@/components/ui/button/BaseButton.vue";
</script>

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

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

    <!-- Overriding sicuro grazie a tailwind-merge -->
    <!-- Pur avendo p-4 di default nel 'md', pt-8 vincerà senza conflitti CSS -->
    <BaseButton class="pt-8 w-full mt-4">
      Bottone Custom
    </BaseButton>
  </div>
</template>
				
			

Conclusioni

L’esternalizzazione delle regole di stile mediante Class Variance Authority consente di separare la definizione della struttura HTML del componente dalla sua configurazione estetica. La definizione di API interne rende lo sviluppo più veloce, prevedibile e a prova d’errore. Questo modello garantisce tre proprietà principali:

  • Tipizzazione rigorosa: rilevamento degli errori durante l’assegnazione delle props relative allo stile.
  • Modularità: separazione netta tra la sintassi del template Vue e la logica delle varianti CSS.
  • Risoluzione deterministica delle classi: prevenzione dei conflitti CSS nell’override delle props grazie all’integrazione di tailwind-merge.

Ultimi articoli

Dicom Sotto Attacco

DICOM sotto attacco: quando l’immagine medica diventa un vettore d’attacco

DICOMweb: portare il medical imaging nelle applicazioni web moderne

NixOS to Serverless

NixOS to Serverless: Moving from VM to AWS Fargate

area contatti

Per informazioni, progetti, idee, scrivici