Tailwind with React and Vue: clsx, tailwind-merge and cva

Advanced
14 min

Tailwind with React and Vue: clsx, tailwind-merge and cva

In a component framework, class strings become values built at runtime, and two problems appear: Tailwind cannot generate classes it never sees in your source, and conflicting utilities do not override each other in the order you expect. In this lesson you will learn the rule that keeps the scanner happy and the three small libraries — clsx, tailwind-merge and class-variance-authority — that keep conditional and variant-driven components clean in React and Vue.

The one rule: complete class names in source

Tailwind scans your files for strings that look like utilities and never executes code, so a class assembled at runtime does not exist in the CSS:

tsx
// Wrong: "bg-red-500" never appears in the source, so it is never generated <div className={`bg-${color}-500`} /> // Right: every complete class name is visible to the scanner const colors = { red: "bg-red-500", green: "bg-green-500" } as const; <div className={colors[color]} />

Map props to full class strings inside your component files and the scanner sees everything it needs. The same rule applies to Vue templates and to strings stored in a database, which the scanner never reads.

Conditional classes with clsx

clsx joins class names and drops anything falsy, replacing fragile template-literal concatenation:

tsx
import clsx from "clsx"; <a className={clsx( "rounded-md px-3 py-2 text-sm", isActive ? "bg-gray-900 text-white" : "text-gray-700 hover:bg-gray-100", disabled && "pointer-events-none opacity-50" )}> {label} </a>

Strings, ternaries, objects and arrays mix freely, and every class name is still written in full, so the scanner rule holds.

Overriding safely with tailwind-merge

A reusable component usually accepts a className prop so callers can adjust it. Plain concatenation breaks here: in "px-4 px-8" the winner is decided by Tailwind's CSS order, not the string order. tailwind-merge understands utilities and keeps only the last one per property:

ts
import { twMerge } from "tailwind-merge"; twMerge("px-4 py-2 bg-blue-600", "px-8 bg-red-600"); // -> "py-2 px-8 bg-red-600"

Combining both into a cn() helper, as in the sample code, gives one function for every className. tailwind-merge knows the default theme; custom values that could conflict (text-hero next to text-lg) are registered through extendTailwindMerge.

Variants with cva

class-variance-authority turns variant options into a function with inferred types:

tsx
const badge = cva("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium", { variants: { tone: { success: "bg-green-100 text-green-800", warning: "bg-yellow-100 text-yellow-800", }, }, defaultVariants: { tone: "success" }, }); <span className={badge({ tone: "warning" })}>Pending</span>

VariantProps<typeof badge> extracts the prop types, so tone="danger" fails at compile time. A cva definition, a cn() merge and a className prop is the convention most React component libraries follow.

Vue specifics

Vue's :class binding accepts objects and arrays natively, so clsx is optional, but tailwind-merge is still valuable for class passthrough:

vue
<script setup lang="ts"> import { computed } from "vue"; import { twMerge } from "tailwind-merge"; const props = defineProps<{ active?: boolean; class?: string }>(); const classes = computed(() => twMerge("rounded-md px-3 py-2", props.active && "bg-gray-900 text-white", props.class) ); </script> <template> <a :class="classes"><slot /></a> </template>

Two details matter in single-file components: a scoped <style> block is compiled separately, so using @apply there requires @reference "../assets/main.css"; (the stylesheet that imports Tailwind) at the top; and because Vue merges a parent's class onto the root element automatically, declare inheritAttrs: false when merging it yourself.

Common mistakes

  • Building class names with template literals, then "fixing" the missing CSS with a large safelist.
  • Omitting "tailwindFunctions": ["clsx", "cn", "cva"] from the Prettier config, leaving classes in those calls unsorted.
Quick Quiz
Question 1 of 2

Why does `className={`text-${size}`}` produce no styling?

Key Takeaways

  • Never build class names at runtime; map props to complete class strings in scanned files.
  • clsx handles conditional classes; tailwind-merge resolves conflicts so className overrides work.
  • A cn() helper combining both is the standard entry point.
  • cva defines typed variants and pairs naturally with cn() and VariantProps.
  • In Vue, use @reference inside scoped <style> blocks and twMerge for class passthrough.

Next lesson: Performance, Content Detection and Production Builds — understand how v4 finds your classes, keep the CSS small and ship an optimised build.

Tailwind with React and Vue: clsx, tailwind-merge and cva - Tailwind CSS | CodeYourCraft | CodeYourCraft