Skip to content
C Codeloom
React

React Tailwind Component Library Patterns

Build a maintainable React component library on top of Tailwind CSS using variants, slots, and class merging — without losing the speed that made Tailwind appealing.

·4 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • How to structure a Tailwind component API
  • Variants with class-variance-authority
  • Class merging with tailwind-merge
  • Composition with slots and asChild
  • Pitfalls in scaling Tailwind components

Prerequisites

  • Familiar with React and Tailwind basics

Tailwind makes prototyping fast, but a real component library on top of it needs structure. Otherwise you end up with copy-pasted class strings and no way to enforce design tokens. This post lays out the patterns that scale.

What and Why

Tailwind gives you utility classes that map directly to CSS. That speed is its superpower but also a trap. Without a component layer, every button across your app accumulates slightly different class strings. The design drifts and overrides fight each other.

A component library wraps Tailwind in a typed React API. Consumers pick from a small set of variants, and the component generates the right classes. You preserve Tailwind’s speed inside the library while presenting a stable surface outside.

Three tools do most of the work: clsx for conditional classes, tailwind-merge for resolving conflicts, and class-variance-authority (CVA) for typed variants.

Mental Model

Think of each component as a function from props to a class string. Variants are the buckets of choice, like size, intent, and state. The class string is computed deterministically. If a consumer passes their own className, it merges on top with Tailwind’s last-wins rules respected by tailwind-merge.

For composition, you expose slots or an asChild escape hatch that lets the consumer render a different element while keeping your styles. That keeps the API small without locking you in.

Hands-on Example

Here is a typed button with two variants and class merging.

design tokens (tailwind.config)
     |
     v
 variants (cva)
     |
     v
component API (props)
     |
     v
 className string
     |
     v
 DOM element
Component layering on top of Tailwind
import { cva, type VariantProps } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';
import clsx from 'clsx';

const button = cva(
  'inline-flex items-center justify-center rounded font-medium transition',
  {
    variants: {
      intent: {
        primary: 'bg-blue-600 text-white hover:bg-blue-700',
        ghost: 'bg-transparent text-blue-600 hover:bg-blue-50',
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        md: 'h-10 px-4 text-base',
      },
    },
    defaultVariants: { intent: 'primary', size: 'md' },
  },
);

type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof button>;

export function Button({ intent, size, className, ...rest }: Props) {
  return (
    <button className={twMerge(clsx(button({ intent, size }), className))} {...rest} />
  );
}

// Usage
<Button intent="ghost" size="sm">Cancel</Button>
<Button className="w-full">Save</Button>

CVA gives the typed surface. clsx joins the parts. tailwind-merge makes sure that a user-passed bg-red-500 correctly overrides the variant’s bg-blue-600, rather than both classes being applied with arbitrary CSS order winning.

Common Pitfalls

Skipping tailwind-merge is the most painful bug. Without it, you cannot reliably override component styles from the outside. You spend hours debugging why your prop did not take effect.

Defining variants as raw template strings instead of CVA loses type safety. You also lose the default-variant feature, which is a small but constant productivity hit.

Letting consumers pass arbitrary class names without any structure leads to drift just as bad as no library. Lock styling decisions inside variants and reserve className for layout-level concerns like width and margin.

Building a hundred-component library from day one is a classic overreach. Ship a button, an input, and a modal. See what your app actually needs before generalizing.

Best Practices

Co-locate the variants with the component. The CVA call sits in the same file as the JSX so reviewers see the whole API in one place.

Expose an asChild prop using Radix’s Slot pattern when you need composition. It lets a consumer render a link styled as a button without duplicating logic.

Keep design tokens in tailwind.config and reference them by name. Brand color shifts then become a one-line change instead of a global find-and-replace.

Document each component with a small story showing every variant. Visual regression tools catch unintended drift.

Wrap-up

Tailwind plus React plus CVA plus tailwind-merge is the modern recipe for a fast, typed component library. Lean on the tools, keep the surface small, and resist the urge to generalize early. Your design system stays consistent without sacrificing Tailwind’s speed.