Tailwind CSS React TypeScript Developer Experience (DX)

Tailwind Without the Mess: A Component Styling System That Scales

Static Signal
An intricate steampunk loom weaving glowing threads of light into a perfectly organized fabric pattern

Open any Tailwind project that’s been running for six months. Find the primary button component. Count the classes.

I’ve seen buttons with 30+ utility classes crammed into a single className string. Conditional variants jammed in with ternaries. Dark mode classes doubled on every element. Hover, focus, disabled, active — all inline, all interleaved, all unreadable. The component works. Nobody wants to touch it.

Tailwind’s utility-first model is genuinely good. The problem isn’t the framework. It’s the absence of a system for organizing the utilities once your components need more than one visual state. The answer isn’t CSS modules or styled-components. It’s two small libraries that Tailwind was always meant to be paired with: cva and clsx.


The Problem in Practice

Here’s what a button component looks like without a system:

<button
  className={`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-offset-2 disabled:pointer-events-none disabled:opacity-50
    ${variant === "primary"
      ? "bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
      : variant === "secondary"
      ? "bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100"
      : "border border-gray-300 bg-transparent hover:bg-gray-100 dark:border-gray-600"
    } ${size === "sm" ? "h-8 px-3 text-xs" : size === "lg" ? "h-12 px-8 text-base" : "h-10 px-4"}`}
>
  {children}
</button>

This is real. I’ve pulled variations of this from production codebases. It compiles fine. It looks correct in the browser. But try adding a new variant six months from now. Try reviewing a PR that changes the disabled styles. Try explaining to a new team member which ternary controls the dark mode hover state.

The code is write-once. That’s the problem.


cva: Class Variance Authority

cva gives you a structured way to define component variants. Instead of conditional ternaries in your JSX, you declare your variants up front in a single object. The library returns a function that resolves the right classes based on the props you pass.

npm install class-variance-authority clsx tailwind-merge

Now rewrite that button:

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

const buttonVariants = cva(
  "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-offset-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        primary: "bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600",
        secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100",
        outline: "border border-gray-300 bg-transparent hover:bg-gray-100 dark:border-gray-600",
        ghost: "hover:bg-gray-100 dark:hover:bg-gray-800",
      },
      size: {
        sm: "h-8 px-3 text-xs",
        md: "h-10 px-4",
        lg: "h-12 px-8 text-base",
      },
    },
    defaultVariants: {
      variant: "primary",
      size: "md",
    },
  }
);

export type ButtonVariants = VariantProps<typeof buttonVariants>;

Same classes. Same output. But now each variant is named, isolated, and readable. Adding a destructive variant is one line. Changing the sm size is one place. Reviewing a PR means reading a diff that shows exactly which variant changed, not parsing a nested ternary.


The Component Pattern

The component itself stays clean:

import { cn } from "@/lib/utils";
import { buttonVariants, type ButtonVariants } from "./button.variants";

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    ButtonVariants {
  className?: string;
}

export function Button({ className, variant, size, ...props }: ButtonProps) {
  return (
    <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
  );
}

That cn helper is the glue. It’s typically clsx + tailwind-merge in one function:

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

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

clsx handles conditional class joining — arrays, objects, falsy values all resolve cleanly. tailwind-merge handles class conflicts — if you pass className="bg-red-500" to a button with bg-blue-600 as its default, tailwind-merge resolves the conflict correctly instead of leaving both classes in the DOM.

This is important. Without tailwind-merge, overriding component styles from the outside is unreliable. The CSS specificity war that Tailwind was supposed to eliminate comes back through the side door.


Compound Variants

The real power shows up when variants interact. Say your destructive variant needs different ring colors when focused, but only at certain sizes:

const buttonVariants = cva("...", {
  variants: {
    variant: {
      primary: "bg-blue-600 text-white hover:bg-blue-700",
      destructive: "bg-red-600 text-white hover:bg-red-700",
    },
    size: {
      sm: "h-8 px-3 text-xs",
      lg: "h-12 px-8 text-base",
    },
  },
  compoundVariants: [
    {
      variant: "destructive",
      size: "lg",
      className: "focus-visible:ring-red-500 uppercase tracking-wide",
    },
  ],
  defaultVariants: {
    variant: "primary",
    size: "sm",
  },
});

Try doing that with ternaries. It’s possible, but the code becomes a branching nightmare. With compoundVariants, the interaction is explicit and documented in the variant definition.


Beyond Buttons

This pattern isn’t just for buttons. It’s a system. Apply it to every component that has visual variants:

const badgeVariants = cva(
  "inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors",
  {
    variants: {
      variant: {
        default: "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-100",
        success: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100",
        warning: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100",
        error: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100",
      },
    },
    defaultVariants: { variant: "default" },
  }
);

Cards, alerts, inputs, navigation items — anything with conditional visual states. The pattern is always the same: define variants with cva, compose with cn, export the types. Every component in your system follows one pattern. New developers learn it once and apply it everywhere.

This is exactly how shadcn/ui is built. That’s not a coincidence — shadcn popularized this pattern precisely because it solves the scaling problem that every Tailwind project eventually hits.


The Rules

After using this system across multiple production codebases, these are the conventions that hold up:

One variants file per component. If your component is Button.tsx, your variants live in button.variants.ts or alongside the component. Don’t centralize all variants into a single file — that creates a bottleneck.

Always export the variant types. VariantProps<typeof buttonVariants> gives you type-safe props for free. Consumers of your component get autocomplete for every variant. Invalid combinations fail at compile time.

Always use cn for the final className. Even if the component doesn’t accept an external className prop today, it will eventually. Wrapping in cn from the start means you never have to refactor the component to support overrides.

Keep base classes in the first argument. The first argument to cva is your base — classes that apply regardless of variant. States like disabled, focus-visible, and transition belong here. Variant-specific classes go in the variants object. If a class appears in every variant, move it to the base.

Don’t nest cva calls. One variant definition per component. If a component is complex enough to need multiple variant dimensions, use compoundVariants. If it’s complex enough to need nested variant resolution, it’s complex enough to be two components.


Tailwind’s utility model is good. The gap was always a conventions layer for managing those utilities at scale. cva + clsx + tailwind-merge is that layer. It’s not a framework. It’s not an abstraction that hides Tailwind. It’s a pattern for keeping Tailwind readable as your component library grows past five components.

The teams that adopt this early never hit the “Tailwind is unreadable” wall. The teams that don’t adopt it eventually rewrite their components anyway — and usually land on something close to this.

Start with the cn helper and one cva definition. The rest follows naturally.


Static Signal is published by Neuron Web Development.