A lightweight, type-safe library for building composable React components with dynamic CSS class variations. Works seamlessly with Tailwind CSS, CSS Modules, or any CSS solution.
Building UI components often requires managing multiple visual states and combinations. React Class Variants provides a powerful API inspired by Stitches.js that makes this trivial:
// Define variants once
const Button = variantComponent('button', {
variants: {
color: {
primary: 'bg-blue-500 text-white',
secondary: 'bg-gray-500 text-white',
},
size: {
sm: 'px-3 py-1 text-sm',
lg: 'px-6 py-3 text-lg',
},
},
});
// Use anywhere with full type safety
<Button color="primary" size="lg">
Click me
</Button>;No more messy className logic, no more props duplication, just clean, type-safe components.
- 🎯 Type-Safe - Automatic TypeScript inference for all variant combinations
- 🎨 Flexible - Works with Tailwind CSS, CSS Modules, or plain CSS classes
- ⚡ Lightweight - Zero dependencies (~2KB minified + gzipped)
- 🔀 Compound Variants - Apply styles based on multiple variant combinations
- 🎭 Polymorphic - Render components as different elements with full type safety
- 🔧 Smart Merging - Optional class conflict resolution via
tailwind-mergeor custom function - 📦 Tree-Shakeable - Import only what you need
- ⚛️ React 19 Ready - Full support for modern React
- Why React Class Variants?
- Features
- Installation
- Quick Start
- Tailwind CSS IntelliSense
- Core Concepts
- API Reference
- TypeScript
- Usage with Different CSS Solutions
- Real-World Examples
- Advanced Patterns
- Performance
- Comparison
- Contributing
- License
- Links
npm install react-class-variantsyarn add react-class-variantspnpm add react-class-variantsOptional: For Tailwind CSS class conflict resolution:
npm install tailwind-mergeimport { defineConfig } from 'react-class-variants';
const { variants } = defineConfig();
// Create a variant function
const buttonClasses = variants({
base: 'font-semibold rounded transition',
variants: {
color: {
blue: 'bg-blue-500 text-white hover:bg-blue-600',
gray: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
},
},
});
// Use it
function MyButton() {
return <button className={buttonClasses({ color: 'blue' })}>Click me</button>;
}import { defineConfig } from 'react-class-variants';
const { variantComponent } = defineConfig();
const Button = variantComponent('button', {
base: 'font-semibold rounded transition',
variants: {
color: {
blue: 'bg-blue-500 text-white hover:bg-blue-600',
gray: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
},
size: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2',
lg: 'px-6 py-3 text-lg',
},
},
defaultVariants: {
color: 'blue',
size: 'md',
},
});
// Component is fully typed!
function App() {
return (
<Button color="gray" size="lg">
Hello
</Button>
);
}import { defineConfig } from 'react-class-variants';
import { twMerge } from 'tailwind-merge';
// Configure once for your entire app
const { variants, variantComponent } = defineConfig({
onClassesMerged: twMerge, // Handles conflicting Tailwind classes
});
const Button = variantComponent('button', {
base: 'px-4 py-2', // These get properly merged...
variants: {
spacing: {
tight: 'px-2 py-1', // ...with these
wide: 'px-8 py-4',
},
},
});
// px-4 from base is overridden by px-2 from variant
<Button spacing="tight" />;If you're using Tailwind CSS, you can enable autocompletion and syntax highlighting for class names inside your variant configurations.
-
Install the Tailwind CSS IntelliSense extension for VS Code
-
Add the following configuration to your VS Code
settings.json:
{
"tailwindCSS.classFunctions": [
"variants",
"variantPropsResolver",
"variantComponent"
]
}- Now you'll get full IntelliSense support in your variant configurations:
import { defineConfig } from 'react-class-variants';
const { variantComponent } = defineConfig();
const Button = variantComponent('button', {
base: 'px-5 py-2 text-white transition-colors',
variants: {
color: {
neutral: 'bg-slate-500 hover:bg-slate-400', // Full IntelliSense here
accent: 'bg-teal-500 hover:bg-teal-400',
},
size: {
sm: 'text-sm',
lg: 'text-lg',
},
},
});You'll get:
- Autocompletion for Tailwind classes
- Hover previews showing the actual CSS
- Linting for invalid or conflicting classes
- Color decorators
Variants are different visual states of a component:
const alert = variants({
variants: {
variant: {
info: 'bg-blue-100 text-blue-900 border-blue-200',
success: 'bg-green-100 text-green-900 border-green-200',
warning: 'bg-yellow-100 text-yellow-900 border-yellow-200',
error: 'bg-red-100 text-red-900 border-red-200',
},
},
});
alert({ variant: 'success' }); // Returns success classesUse "true" and "false" string keys for boolean props:
const button = variants({
variants: {
outlined: {
true: 'border-2 bg-transparent',
false: 'border-0',
},
disabled: {
true: 'opacity-50 cursor-not-allowed',
},
},
});
// Usage
<Button outlined /> // outlined: true
<Button outlined={false} /> // outlined: false
<Button disabled /> // disabled: trueApply styles when multiple variants match:
const button = variants({
variants: {
color: {
primary: 'bg-blue-500',
secondary: 'bg-gray-500',
},
size: {
sm: 'text-sm',
lg: 'text-lg',
},
},
compoundVariants: [
{
variants: {
color: 'primary',
size: 'lg',
},
className: 'font-bold shadow-lg',
},
],
});
// Gets: bg-blue-500 + text-lg + font-bold shadow-lg
button({ color: 'primary', size: 'lg' });Compound variants support array matching (OR condition):
compoundVariants: [
{
variants: {
color: ['primary', 'secondary'], // Matches if primary OR secondary
size: 'lg',
},
className: 'uppercase',
},
];Make variants optional by providing defaults:
const button = variants({
variants: {
color: {
primary: 'bg-blue-500',
secondary: 'bg-gray-500',
},
size: {
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg',
},
},
defaultVariants: {
color: 'primary', // Now optional
size: 'md', // Now optional
},
});
// All equivalent:
button({});
button({ color: 'primary' });
button({ size: 'md' });
button({ color: 'primary', size: 'md' });Render components as different elements while preserving styles:
const Button = variantComponent('button', {
base: 'px-4 py-2 rounded font-semibold',
variants: {
color: {
primary: 'bg-blue-500 text-white',
},
},
});
// Render as a link
<Button color="primary" render={<a href="/home" />}>
Go Home
</Button>;
// Render with custom component
import { Link } from 'react-router-dom';
<Button color="primary" render={props => <Link {...props} to="/home" />}>
Go Home
</Button>;Props, refs, and event handlers are automatically merged!
Note: The
renderprop pattern is a well-established composition pattern in the React ecosystem, used by libraries like Base UI and Ariakit for building accessible, composable components.
Creates a configured factory for creating variants and components.
const config = defineConfig({
onClassesMerged?: (classNames: string) => string;
});Options:
onClassesMerged- Function to merge/process final class names (e.g.,twMerge)
Returns:
variants- Function to create class name resolversvariantComponent- Function to create React componentsvariantPropsResolver- Function to create props resolvers
Creates a function that resolves variant props to class names.
const buttonVariants = variants({
base?: string | string[] | null;
variants?: {
[variantName: string]: {
[variantValue: string]: string | string[] | null;
};
};
compoundVariants?: Array<{
variants: Record<string, string | string[]>;
className: string | string[] | null;
}>;
defaultVariants?: Record<string, string>;
});Returns: (props) => string
Creates a React component with variant support.
const Button = variantComponent(
element: string | React.ComponentType,
config: VariantsConfig & {
withoutRenderProp?: boolean;
forwardProps?: string[];
}
);Config Options:
- All
VariantsConfigoptions (base,variants,compoundVariants,defaultVariants) withoutRenderProp- Disables therenderprop pattern (optional)forwardProps- Array of variant prop names to forward to the rendered element (optional)
Component Props:
- All variant props (inferred from config)
- Native element props (e.g.,
onClick,disabled) className- Additional classes (merged with highest priority)render- Polymorphic rendering (unlesswithoutRenderPropis true)
Creates a function that extracts variant props and resolves them to a className.
const resolveButtonProps = variantPropsResolver(config);
const { className, ...rest } = resolveButtonProps({
color: 'primary',
size: 'lg',
onClick: handleClick,
});
// className: resolved variant classes
// rest: { onClick: handleClick }Full TypeScript support with automatic type inference.
const Button = variantComponent('button', {
variants: {
color: {
primary: 'bg-blue-500',
secondary: 'bg-gray-500',
},
size: {
sm: 'text-sm',
lg: 'text-lg',
},
},
defaultVariants: {
size: 'sm',
},
});
// TypeScript knows:
// ✅ color is required (no default)
// ✅ size is optional (has default)
// ✅ color only accepts 'primary' | 'secondary'
// ✅ size only accepts 'sm' | 'lg'
<Button color="primary" /> // ✅
<Button color="invalid" /> // ❌ Type error
<Button size="sm" /> // ❌ Type error (missing color)
<Button color="primary" size="lg" /> // ✅Variants are required by default. They become optional when:
- They are boolean variants (
"true"/"false"keys) - They have a value in
defaultVariants
const component = variants({
variants: {
color: { red: '...', blue: '...' }, // Required
size: { sm: '...', lg: '...' }, // Required
outlined: { true: '...', false: '...' }, // Optional (boolean)
},
defaultVariants: {
size: 'sm', // Makes size optional
},
});
// color: required
// size: optional (has default)
// outlined: optional (boolean)React Class Variants provides several utility types for working with variants and components:
import type {
VariantsConfig,
VariantOptions,
ClassNameValue,
VariantComponentProps,
ExtractVariantOptions,
ExtractVariantConfig,
} from 'react-class-variants';
// Extract config type
type Config = VariantsConfig<typeof myConfig>;
// Extract variant props
type Variants = VariantOptions<typeof myConfig>;
// Use in props
type Props = {
className?: ClassNameValue;
};Universal type utility that extracts variant options from any variant function, resolver, or component. Works with:
variants()- className resolver functionsvariantPropsResolver()- props resolver functionsvariantComponent()- React components
This is useful when you need to reference variant props in other parts of your code.
const { variants, variantPropsResolver, variantComponent } = defineConfig();
// Works with variants()
const buttonVariants = variants({
variants: {
color: {
primary: 'bg-blue-500',
secondary: 'bg-gray-500',
},
size: {
sm: 'text-sm',
lg: 'text-lg',
},
},
defaultVariants: {
size: 'sm',
},
});
type ButtonOptions1 = ExtractVariantOptions<typeof buttonVariants>;
// Result: { color: 'primary' | 'secondary', size?: 'sm' | 'lg' }
// Works with variantPropsResolver()
const resolveButtonProps = variantPropsResolver({
variants: {
variant: { solid: 'bg-fill', outline: 'border' },
},
});
type ButtonOptions2 = ExtractVariantOptions<typeof resolveButtonProps>;
// Result: { variant: 'solid' | 'outline' }
// Works with variantComponent()
const Button = variantComponent('button', {
variants: {
color: { primary: 'bg-blue-500' },
},
});
type ButtonOptions3 = ExtractVariantOptions<typeof Button>;
// Result: { color: 'primary' }
// Use in your own components
function ButtonGroup({ variant }: { variant: ButtonOptions1['color'] }) {
return (
<div>
<Button color={variant} />
<Button color={variant} />
</div>
);
}Key Points:
- Universal: Works with all three core functions (
variants,variantPropsResolver,variantComponent) - Respects optional vs required variants (based on
defaultVariantsand boolean variants) - Includes only variant props (excludes native element props like
onClick,className, etc.) - Useful for prop forwarding and composition
Universal type utility that extracts the full configuration from any variant function, resolver, or component. Works with:
variants()- className resolver functionsvariantPropsResolver()- props resolver functionsvariantComponent()- React components
This is useful for reusing or extending configurations.
const { variants, variantPropsResolver, variantComponent } = defineConfig();
// Works with variants()
const buttonVariants = variants({
base: 'rounded font-semibold',
variants: {
color: {
primary: 'bg-blue-500',
secondary: 'bg-gray-500',
},
},
defaultVariants: {
color: 'primary',
},
compoundVariants: [
{
variants: { color: 'primary' },
className: 'shadow-lg',
},
],
});
type ButtonConfig1 = ExtractVariantConfig<typeof buttonVariants>;
// Result: {
// base?: ClassNameValue,
// variants?: { color: { primary: string, secondary: string } },
// defaultVariants?: { color: 'primary' | 'secondary' },
// compoundVariants?: Array<...>
// }
// Works with variantPropsResolver()
const resolveProps = variantPropsResolver({
base: 'input',
variants: { size: { sm: 'h-8', lg: 'h-12' } },
});
type ResolverConfig = ExtractVariantConfig<typeof resolveProps>;
// Works with variantComponent()
const Button = variantComponent('button', {
base: 'btn',
variants: { color: { primary: 'bg-blue' } },
});
type ButtonConfig2 = ExtractVariantConfig<typeof Button>;
// Reuse config with modifications
const dangerVariants = variants({
...(buttonVariants as any), // Note: need type assertion for runtime config access
variants: {
color: {
danger: 'bg-red-500 text-white',
warning: 'bg-yellow-500 text-black',
},
},
});Key Points:
- Universal: Works with all three core functions (
variants,variantPropsResolver,variantComponent) - Extracts the complete
VariantsConfigincludingbase,variants,defaultVariants, andcompoundVariants - Useful for creating derived components or sharing configurations
- Returns a prettified type for better IDE support
import { defineConfig } from 'react-class-variants';
import { twMerge } from 'tailwind-merge';
const { variantComponent } = defineConfig({
onClassesMerged: twMerge,
});
const Button = variantComponent('button', {
base: 'rounded font-medium transition-colors',
variants: {
color: {
blue: 'bg-blue-500 hover:bg-blue-600 text-white',
red: 'bg-red-500 hover:bg-red-600 text-white',
},
},
});import { defineConfig } from 'react-class-variants';
import styles from './Button.module.css';
const { variantComponent } = defineConfig();
const Button = variantComponent('button', {
base: styles.button,
variants: {
color: {
primary: styles.primary,
secondary: styles.secondary,
},
size: {
sm: styles.small,
lg: styles.large,
},
},
});import { defineConfig } from 'react-class-variants';
import './Button.css';
const { variantComponent } = defineConfig();
const Button = variantComponent('button', {
base: 'btn',
variants: {
color: {
primary: 'btn-primary',
secondary: 'btn-secondary',
},
},
});import { defineConfig } from 'react-class-variants';
import { twMerge } from 'tailwind-merge';
import styles from './Button.module.css';
const { variantComponent } = defineConfig({
onClassesMerged: twMerge,
});
const Button = variantComponent('button', {
base: [styles.button, 'transition-all'],
variants: {
color: {
primary: [styles.primary, 'shadow-lg'],
secondary: [styles.secondary, 'shadow-md'],
},
},
});import { defineConfig } from 'react-class-variants';
import { twMerge } from 'tailwind-merge';
const { variantComponent } = defineConfig({ onClassesMerged: twMerge });
export const Button = variantComponent('button', {
base: [
'inline-flex items-center justify-center',
'font-medium rounded-lg',
'transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
],
variants: {
variant: {
solid: '',
outline: 'bg-transparent border-2',
ghost: 'bg-transparent',
},
color: {
blue: '',
red: '',
green: '',
gray: '',
},
size: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
},
},
compoundVariants: [
// Solid variants
{
variants: { variant: 'solid', color: 'blue' },
className: 'bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-500',
},
{
variants: { variant: 'solid', color: 'red' },
className: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
},
{
variants: { variant: 'solid', color: 'green' },
className:
'bg-green-600 hover:bg-green-700 text-white focus:ring-green-500',
},
{
variants: { variant: 'solid', color: 'gray' },
className: 'bg-gray-600 hover:bg-gray-700 text-white focus:ring-gray-500',
},
// Outline variants
{
variants: { variant: 'outline', color: 'blue' },
className:
'border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-blue-500',
},
{
variants: { variant: 'outline', color: 'red' },
className:
'border-red-600 text-red-600 hover:bg-red-50 focus:ring-red-500',
},
// Ghost variants
{
variants: { variant: 'ghost', color: 'blue' },
className: 'text-blue-600 hover:bg-blue-50 focus:ring-blue-500',
},
],
defaultVariants: {
variant: 'solid',
color: 'blue',
size: 'md',
},
});Usage:
<Button>Default</Button>
<Button variant="outline" color="red" size="lg">Outline</Button>
<Button variant="ghost" color="green">Ghost</Button>
<Button disabled>Disabled</Button>
<Button render={<a href="/" />}>Link Button</Button>export const Card = variantComponent('div', {
base: 'rounded-lg overflow-hidden',
variants: {
variant: {
elevated: 'shadow-md hover:shadow-lg transition-shadow',
outlined: 'border border-gray-200',
filled: 'bg-gray-50',
},
padding: {
none: 'p-0',
sm: 'p-4',
md: 'p-6',
lg: 'p-8',
},
},
defaultVariants: {
variant: 'elevated',
padding: 'md',
},
});
export const CardHeader = variantComponent('div', {
base: 'border-b border-gray-200 pb-4 mb-4',
});
export const CardTitle = variantComponent('h3', {
base: 'text-lg font-semibold text-gray-900',
});
export const CardContent = variantComponent('div', {
base: 'text-gray-600',
});Usage:
<Card>
<CardHeader>
<CardTitle>Card Title</CardTitle>
</CardHeader>
<CardContent>Card content goes here</CardContent>
</Card>export const Badge = variantComponent('span', {
base: 'inline-flex items-center font-medium rounded-full',
variants: {
variant: {
solid: '',
outline: 'border bg-transparent',
subtle: '',
},
color: {
gray: '',
blue: '',
green: '',
yellow: '',
red: '',
},
size: {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-0.5 text-sm',
lg: 'px-3 py-1 text-base',
},
},
compoundVariants: [
// Solid
{
variants: { variant: 'solid', color: 'gray' },
className: 'bg-gray-100 text-gray-800',
},
{
variants: { variant: 'solid', color: 'blue' },
className: 'bg-blue-100 text-blue-800',
},
{
variants: { variant: 'solid', color: 'green' },
className: 'bg-green-100 text-green-800',
},
{
variants: { variant: 'solid', color: 'yellow' },
className: 'bg-yellow-100 text-yellow-800',
},
{
variants: { variant: 'solid', color: 'red' },
className: 'bg-red-100 text-red-800',
},
// Outline
{
variants: { variant: 'outline', color: 'blue' },
className: 'border-blue-500 text-blue-700',
},
// Subtle
{
variants: { variant: 'subtle', color: 'blue' },
className: 'bg-blue-50 text-blue-700',
},
],
defaultVariants: {
variant: 'solid',
color: 'gray',
size: 'md',
},
});export const Input = variantComponent('input', {
base: [
'w-full rounded-md border transition-colors',
'focus:outline-none focus:ring-2 focus:ring-offset-1',
'disabled:opacity-50 disabled:cursor-not-allowed',
],
variants: {
variant: {
outline:
'bg-white border-gray-300 focus:border-blue-500 focus:ring-blue-200',
filled:
'bg-gray-100 border-transparent focus:bg-white focus:ring-blue-200',
flushed:
'bg-transparent border-t-0 border-x-0 border-b-2 rounded-none focus:ring-0',
},
size: {
sm: 'px-2 py-1.5 text-sm',
md: 'px-3 py-2 text-base',
lg: 'px-4 py-3 text-lg',
},
error: {
true: 'border-red-500 focus:border-red-500 focus:ring-red-200',
},
},
defaultVariants: {
variant: 'outline',
size: 'md',
},
});// config/variants.ts
import { defineConfig } from 'react-class-variants';
import { twMerge } from 'tailwind-merge';
export const { variants, variantComponent } = defineConfig({
onClassesMerged: twMerge,
});
// components/Button.tsx
import { variantComponent } from '@/config/variants';
export const Button = variantComponent('button', { ... });
// components/Card.tsx
import { variantComponent } from '@/config/variants';
export const Card = variantComponent('div', { ... });const BaseButton = variantComponent('button', {
base: 'rounded font-medium',
variants: {
size: {
sm: 'px-3 py-1',
lg: 'px-6 py-3',
},
},
});
// Extend with additional props
const IconButton = ({
icon,
children,
...props
}: React.ComponentProps<typeof BaseButton> & { icon: React.ReactNode }) => {
return (
<BaseButton {...props}>
{icon}
{children}
</BaseButton>
);
};const createColorVariants = (colors: string[]) => {
return colors.reduce((acc, color) => {
acc[color] = `bg-${color}-500 text-white hover:bg-${color}-600`;
return acc;
}, {} as Record<string, string>);
};
const Button = variantComponent('button', {
variants: {
color: createColorVariants(['blue', 'red', 'green', 'purple']),
},
});By default, variant props are consumed and not passed to the rendered element. Use forwardProps to forward specific variant props:
const Button = variantComponent('button', {
base: 'px-4 py-2 rounded font-medium transition-colors',
variants: {
color: {
primary: 'bg-blue-600 hover:bg-blue-700 text-white',
secondary: 'bg-gray-600 hover:bg-gray-700 text-white',
},
disabled: {
true: 'opacity-50 cursor-not-allowed',
false: '',
},
},
// Forward 'disabled' prop to the <button> element
forwardProps: ['disabled'],
});
// The 'color' prop is consumed (not forwarded)
// The 'disabled' prop is both used for styling AND forwarded as HTML attribute
<Button color="primary" disabled>
Submit
</Button>;This is useful when you want variant props to also be available as HTML attributes (like disabled, aria-*, data-*) or for integration with third-party components that expect certain props.
React Class Variants is optimized for performance:
- Zero Runtime Dependencies - Only peer dependency is React
- Minimal Bundle Size - ~2KB minified + gzipped
- Efficient Caching - Boolean variant lookups are cached
- No Re-renders - Components only re-render when props change
- Tree-Shakeable - Import only what you use
| react-class-variants | CVA | classname-variants | tailwind-variants | Stitches | |
|---|---|---|---|---|---|
| Framework | React | Agnostic | React | Agnostic | React |
| TypeScript | ✅ | ✅ | ✅ | ✅ | ✅ |
| Variants | ✅ | ✅ | ✅ | ✅ | ✅ |
| Compound Variants | ✅ | ✅ | ✅ | ✅ | ✅ |
| React Components | ✅ Built-in | ❌ | ✅ Built-in | ❌ | ✅ |
| Polymorphic | ✅ Built-in (via render prop) |
❌ | ✅ Built-in (via as prop) |
❌ | ✅ |
| Forward Props | ✅ forwardProps |
❌ | ✅ forwardProps |
❌ | ❌ |
| CSS Solution | Any | Any | Any | Tailwind | CSS-in-JS |
Contributions are welcome! Please check out our Contributing Guide.
# Clone the repo
git clone https://github.com/jackardios/react-class-variants.git
# Install dependencies
pnpm install
# Run tests in watch mode
pnpm dev
# Build
pnpm build
# Run all checks
pnpm ciMIT © Salavat Salakhutdinov
Built with ❤️ for the React community `