Trendy Coder Logo
  • Home
  • About
  • Blog
  • Newsletter

Building React Components with Variants Using Tailwind CSS

Posted on: June 11 2024
By Dave Becker
Hero image for Building React Components with Variants Using Tailwind CSS

Using Tailwind CSS is a great framework for quickly building rich user interfaces with it's extensive utility first classes, however, adding style variants to custom components can quickly become complex to manage without a good strategy.

In this article I'll be covering some strategies for building components with variants using Tailwind CSS, including:

  • What are component variants?
  • How to add variants
  • Compound variants
  • Creating reusable variants
  • Strategies for good component composition

What are Tailwind CSS component variants?

Tailwind CSS has the concept of variants at the plugin level but component variants in this case are at the component level.

Component variants are React props with type-safe preset options that change a component's styles by managing what Tailwind CSS utility classes get applied based on the prop value.

Why are Tailwind CSS component variants important?

Having a good strategy to apply Tailwind CSS class variants will improve the following:

  • Makes components easier to read without tons of logic to add or remove classes.
  • Maintains a uniform consistency in naming and variant options.
  • Type-safe variant options.
  • Better overall component composition.
  • Variants can be resusable for other components

Prerequisites

I'll assume that you have a React app with Tailwind CSS already configured. If not head over and get that set up first.

Required libraries

Also, you'll need to install some packages:

  • cva: class variance authority which is a lightweight, type-safe, CSS-in-TS library to extract and apply Tailwind CSS variant classes very easily.
  • clsx: A library to conditionally apply classes and also used by cva internally.
  • tailwind-merge: Utility function to merge Tailwind CSS classes without potential style conflicts.
npm i cva clsx tailwind-merge

Merging Tailwind CSS

When applying several Tailwind CSS utility classes it has become good practice to use a merge utility to avoid any style conflicts. The tailwind-merge utility library does this very efficiently.

We'll add a cn utility function which is short for className. You can rename to your preference. This function will do all of the merging for us using the tailwind-merge and clsx functions.

utils.ts

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

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


Tailwind CSS Intellisense

Lastly, if you don't have a Tailwind CSS code intellisense extension, I highly recommend adding Tailwind CSS IntelliSense Visual Studio Code extension.

Adding the following regex lines to your Visual Studio Code settings.json will allow for Tailwind CSS intellisense for classes defined inside the cva() and cn() functions we'll be using.

settings.json
{
 "tailwindCSS.experimental.classRegex": [
        ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
        ["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
      ],
}

Getting Started with Tailwind CSS Variants

For this article, I figured we could make something useful like CTA buttons. These are buttons that are commonly used in marketing and they should be big, fun and attractive and call a user's attention to take an action.

For example:


And here's a sample how the JSX would look like for these components.

<div class="w-[300px] flex justify-between flex-col gap-4">
  <DemoCtaButton bgColor="green" shadow="inner">Register Now</DemoCtaButton>

  <DemoCtaButton bgColor="blue" shadow="md">Sign-up For Newsletter</DemoCtaButton>

  <DemoCtaButton bgColor="purple" rounded="full" shadow="lg">Get Started</DemoCtaButton> 

  <DemoCtaButton bgColor="slate" rounded="none">Enroll Today</DemoCtaButton> 
</div>

Using CVA variants with Tailwind CSS

Using class variance authority (cva) is a great way to easily apply Tailwind CSS classes or even groups of classes based on props options.

Defining CVA variants

The cva function takes two arguments:

  • base: Any base classes that apply to component.
  • config: The config is an object that consists of the following:
    • variants: Object containing variant keys and options
    • defaultVariants: (Optional) Default selected variant option values
    • compoundVariants: (Optional) Applies classes when multiple combinations of variant props are set.

For compoundVariants, I'll just add some entries to conditionally toggle the font color based on the bgColor so the text is readable.

This is minimal usage of the compoundVariants, since it can take an array of possible combination but it will at least demonstrate how to use this field.

Now here's the DemoCtaButton component in full with some annotations.

cta.tsx

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

/*
  Defines value similar to how Tailwind CSS 
  defines its config values
*/
const ctaButtonVariants = cva([
  // any base classes defined here
], {
  variants: {
    textColor: {
      grey: '!text-gray-600',
      white: '!text-white',
      black: '!text-black',
    },
    bgColor: {
      white: 'bg-white',
      black: 'bg-black',
      orange: 'bg-orange-500',
      slate: 'bg-slate-500',
      purple: 'bg-purple-600',
      green: 'bg-green-600',
      red: 'bg-red-600',
      blue: 'bg-blue-600'
    },
    fontWeight: {
      normal: 'font-normal',
      semibold: 'font-semibold',
      bold: 'font-bold',
    },
    shadow: {
      none: 'shadow-none',
      inner: 'shadow-gray-300 shadow-inner',
      xs: 'shadow-gray-800 shadow-xs',
      sm: 'shadow-gray-800 shadow-sm',
      md: 'shadow-gray-800 shadow-md',
      lg: 'shadow-gray-800 shadow-lg',
      xl: 'shadow-gray-800 shadow-xl',
      '2xl': 'shadow-gray-800 shadow-2xl',
    },
    rounded: {
      none: 'rounded-none',
      xs: 'rounded',
      sm: 'rounded-sm',
      md: 'rounded-md',
      lg: 'rounded-lg',
      xl: 'rounded-xl',
      '2xl': 'rounded-2xl',
      '3xl': 'rounded-3xl',
      full: 'rounded-full',
    },
    padding: {
      none: 'px-0 py-0',
      xs: 'px-2 py-1',
      sm: 'px-3 py-2',
      md: 'px-5 py-3',
      lg: 'px-7 py-5',
      xl: 'px-10 py-7',
      '2xl': 'px-12 py-10',
      '3xl': 'px-14 py-12',
    },
  },
  defaultVariants: {
    bgColor: 'blue',
    fontWeight: 'semibold',
    shadow: 'none',
    rounded: 'md',
    padding: 'md',
  },
  compoundVariants: [
    {
      bgColor: ['black', 'blue', 'green', 'orange', 'slate', 'red', 'purple'],
      className: 'text-white',
    },
    {
      bgColor: 'white',
      className: 'text-inherit border border-gray-300',
    },
  ],
});

/*
  Creates a type interface using
  - VariantProps<typeof ctaButtonVariants> + 
  - React.ButtonHTMLAttributes<HTMLButtonElement>
*/
export interface DemoCtaButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof ctaButtonVariants> {
 
  /*
     Add any non-cva variant props here
  */
  text?: string;
}


/*
  Define a React.FC<DemoCtaButtonProps> with the new type

  Applies classes by calling ctaButtonVariants(...), notice how it's only
  passing the CVA props + className. The className is added last so the className can override any CVA classes if the consumer needs

  className={ctaButtonVariants({
        textColor,
        bgColor,
        fontWeight,
        rounded,
        shadow,
        padding,
        className,
      })}
  
  Applies `style` prop so runtime styles can be applied as inline styles

  style={style}
*/

export const DemoCtaButton: React.FC<DemoCtaButtonProps> = ({
  text,
  shadow,
  padding,
  textColor,
  bgColor,
  fontWeight,
  rounded,
  className,
  style,
  children,
  ...props
}) => {
  return (
    <button
      className={ctaButtonVariants({
        textColor,
        bgColor,
        fontWeight,
        rounded,
        shadow,
        padding,
        className,
      })}
      style={style}
      {...props}
    >
      {text ? text : children}
    </button>
  );
};


Here's a quick breakdown of what's happening in the code with some extra examples variant props to better illustrate the usage of the config section.


const ctaButtonVariants = cva([
  /* any base classes defined here */ 
], {
  variants: {
    /* 
      Each object key defines a prop name for the variant.
     */
    bgColor: { 
      /* 
        variant options
      */
      green: 'bg-green-600',
      red: 'bg-red-600',
      blue: 'bg-blue-600',
    },

    // examples
    foo: {
      bar: 'w-full',
      bar2: 'w-[1/2]'
    },
    frequency: {
      alpha: 'bg-blue-200',
      beta: 'bg-orange-200'
      omega: 'bg-green-200'
    }
   },
   defaultVariants: {
     /* 
        default `bgColor to blue option
      */
     bgColor: 'blue',
     foo: 'bar2'
   },
   compoundVariants: [
    {
      /* 
        any number of compound variant combinations 
        can be conditionally checked. If the condition is met, then it will 
        add The class/className.
      */
      bgColor: ['black', 'blue', 'green', 'orange', 'slate', 'red', 'purple'],
      foo: ['bar'],
      className: 'text-white',
    },
    {
      foo: cn(isTruthy && 'bar2'),
      frequency: ['omega'],
      className: 'border bg-slate-200',
    }
   ]
 });

As you can see this becomes a powerful way to define and organize variants effectively.

Type-safe variants

By calling the cva function with the base and config parameters, it will return a callable function.

We can now use the ctaButtonVariants function to create a type-safe interface for our new variant props using the VariantProps<ctaButtonVariants> type.

This is really a powerful aspect of cva is its strong typing capabilities.

Since we're building a button component we can extend both the React.ButtonHTMLAttributes<HTMLButtonElement> and the new VariantProps<ctaButtonVariants> to create the needed interface for our component.

The only props type that needs to be added to the interface is the text field for the label of the button.

Defines the interface

export interface DemoCtaButtonProps
 extends React.ButtonHTMLAttributes<HTMLButtonElement>,
   VariantProps<typeof ctaButtonVariants> {

 text?: string;
}


Applying the variant classes

The last step is to actually call the ctaButtonVariants function to build the Tailwind CSS class list which we'll use to set the className attribute.

Notice that it's also passing the className prop into the ctaButtonVariants function to allow for overrides on a per component basis.

Also, using the style attribute is key here, because we should account for any runtime styles that other non-variant props might set.

In my experience it's always best to let the style attribute have the last say.


<button
      className={ctaButtonVariants({
        textColor,
        bgColor,
        fontWeight,
        rounded,
        shadow,
        padding,
        className,
      })}
      style={style}
      {...props}
    >
</button>


Making Tailwind CSS variants modular and reusable

Now that we've seen a working example, let's take a minute to consider how we can make things more organized and reusable.

The cva library was inspired by other libraries like Vanilla Extract which is a great zero-runtime library. However, since we're using Tailwind CSS we should be able to write sharable and reusable code in a similar way.

Let's create an outline for a Card component.

So to make this component more modular, we can extract out the common variants and create a commons file.

Consider the following.

commons.tsx

export const backgroundVariants = cva({
  // some variant definitions here
});

export const colorVariants = cva({
  // some variant definitions here
});

export const boxVariants = cva({
  // some variant definitions here
});

And now we can outline the Card component here and import the shared variants as follows.

card.tsx

import {backgroundVariants, colorVariants, boxVariants} from './commons';
import { cn } from './utils';

const cardVariants = cva({
  // some variant definitions here
});


export interface CardProps
  extends React.HTMLAttributes<HTMLElement>,
    VariantProps<typeof cardVariants>,
    VariantProps<typeof backgroundVariants>,
    VariantProps<typeof colorVariants>,
    VariantProps<typeof boxVariants> { 

   // any other `Card` prop types here
}


export const Card: React.FC<CardProps> = ({
  /* 
     destructure other cva and Card props here, including 
     any common background, color, box variant props 
  */
  className,
  style,
  children,
  ...props
}) => {
  return (
    <div
      className={cn(
        backgroundVariants({
          // pass cva background props here
        }),
        colorVariants({
          // pass cva color props here, 
        }), 
        boxVariants({
          // pass cva box props here, 
        }),
        cardVariants({
          // pass cva card props here, 
          className,
        })
      )}
      style={style}
      {...props}
    >
      {children}
    </div>
  );
}

Now we can reuse variant definitions plus making it easier to manage common variant styles.

Component variants with complex logic

If there are cases where you need to tailor variants based on some conditions. Let's say for example you have some brand or seasonal promotional requirements to temporarily modify a components theme.

class variance authority makes it easy to apply just about any type of logic you'll need to further refine variant styles.

Variants applied using conditional logic

Rather than just using static Tailwind CSS classes, we can also conditionally set values using the cn function.

card example with branding

import { cn } from './utils';
import { cva } from 'class-variance-authority';

/*
  example brand information which can be 
  fetched or maybe is set with some state 
  hooks or providers.
*/
const brand = {
  darkMode: true,
  flags: {
     holiday2023: true
  },
  colors: {
    bg: 'bg-red-600',
    color: 'text-white',
    border: 'border-slate-200',
    darkMode: {
      bg: 'bg-slate-500',
      color: 'text-white',
      border: 'border-slate-400',
    }
  }
};

const cardVariants = cva([
  cn(brand?.flags?.holiday2023 && 'bg-red-600')
], {
  variants: {
    border: { 
      brand: cn(brand?.colors?.border && brand.colors.border),
      dark: cn(brand?.darkMode && brand.colors.darkMode.border)
    },
    bgColor: {
      brand: cn(brand?.colors?.bg && brand.colors.bg),
      dark: cn(brand?.darkMode && brand.colors.darkMode.bg)
    }
   },
   defaultVariants: {},
   compoundVariants: []
 });

As you can see, any variant can use the cn utility, including defaultVariants and compoundVariants.

Here's a quick reference chart for some of the other possible ways to apply conditional classes inside the cva function.

Each variant can be a clsx class value with any number of conditions.

The cn utility function could use the following possible class value logic.

reference for cn utility

// Strings (variadic)
cn('border-2', true && 'p-3', 'flex');
//=> 'border-2 p-3 flex'

// Objects
cn({ 'border-2':true, 'p-3':false, 'flex':isTrue() });
//=> 'border-2 flex'

// Objects (variadic)
cn({ 'border-2':true }, { 'p-3':false }, null, { '--prime-theme-bg':'foo' });
//=> 'border-2 --prime-theme-bg'

// Arrays
cn(['border-2', 0, false, 'p-3']);
//=> 'border-2 p-3'

// Arrays (variadic)
cn(['border-2'], ['', 0, false, 'p-3'], [['flex', [['bg-slate-300'], 'shadow']]]);
//=> 'border-2 p-3 flex bg-slate-300 shadow'

// Kitchen sink (with nesting)
cn('border-2', [1 && 'p-3', { 'flex':false, 'justify-center':null }, ['bg-slate-300', ['m-4']]], 'shadow');
//=> 'border-2 p-3 bg-slate-300 m-4 shadow'


Conclusion

At this point we've seen some examples of how to use CVA for component variant composition using Tailwind CSS.

With so many technologies out there, this is definitely a combination I use frequently to quickly build quality components with Tailwind CSS.

Using Tailwind CSS and CVA together allows for more focus on building the component business logic rather than maintaining complex variant logic inside each component.

I hope this article has helped to shed some light on these two technologies.

Topics

SEOLinuxSecuritySSHEmail MarketingMore posts...

Related Posts

Hero image for Boost Payload CMS with Search: Step-by-Step Tutorial
Posted on: August 11 2025
By Dave Becker
Boost Payload CMS with Search: Step-by-Step Tutorial
Hero image for Server-Side Pagination Made Easy in Payload CMS
Posted on: August 11 2025
By Dave Becker
Server-Side Pagination Made Easy in Payload CMS
Hero image for Payload CMS: Getting Started Using the New Join Field
Posted on: August 11 2025
By Dave Becker
Payload CMS: Getting Started Using the New Join Field
Hero image for Maximizing Efficiency: The Power of Payload CMS Blocks
Posted on: August 11 2025
By Dave Becker
Maximizing Efficiency: The Power of Payload CMS Blocks
Hero image for Create Custom Forms Using Payload CMS Form Builder Plugin
Posted on: August 11 2025
By Dave Becker
Create Custom Forms Using Payload CMS Form Builder Plugin
Hero image for Payload CMS SEO Plugin: Boosting Your Site's Search Ranking
Posted on: April 04 2025
By Dave Becker
Payload CMS SEO Plugin: Boosting Your Site's Search Ranking
Hero image for GraphQL Optimization in Payload CMS
Posted on: April 04 2025
By Dave Becker
GraphQL Optimization in Payload CMS
Hero image for Exploring the Game-Changing Features of Payload CMS 3.0
Posted on: April 04 2025
By Dave Becker
Exploring the Game-Changing Features of Payload CMS 3.0
Hero image for Document Nesting With Payload's Nested Docs Plugin
Posted on: April 04 2025
By Dave Becker
Document Nesting With Payload's Nested Docs Plugin
Hero image for Payload CMS Collections: How They Streamline Content Management
Posted on: April 04 2025
By Dave Becker
Payload CMS Collections: How They Streamline Content Management
Trendy Coder Logo
Resources
  • Blog
Website
  • Home
  • About us
Subscribe

Get the latest news and articles to your inbox periodically.

We respect your email privacy

© TrendyCoder.com. All rights reserved.