css-variants is no longer maintained. Use @lynstack/class-recipe: faster, stricter types, and React Native support. Migrate →
No longer maintained. Migrate to class-recipe →
css-variants is no longer maintained. Use @lynstack/class-recipe: faster, stricter types, and React Native support. Migrate →
No longer maintained. Migrate to class-recipe →
A CSS variant library is a JavaScript/TypeScript tool that helps you manage component styling through variants. Instead of writing conditional class logic like className={isPrimary ? 'btn-primary' : 'btn-secondary'}, you define variants declaratively and the library generates the correct classes.
css-variants provides functions like cv() to define variants:
const button = cv({ base: 'btn', variants: { color: { primary: 'btn-primary', secondary: 'btn-secondary', }, },})
// Clean APIbutton({ color: 'primary' }) // => 'btn btn-primary'css-variants is a zero-dependency, type-safe library for managing CSS class variants in JavaScript/TypeScript applications. It provides:
cv() — Create variants for single-element components (returns class string)scv() — Create variants for multi-slot components (returns object of class strings)sv() — Create variants for inline styles (returns style object)ssv() — Create variants for multi-slot inline styles (returns object of style objects)cx() — Merge class names conditionally (like clsx)css-variants is ideal for:
css-variants has a very similar API to CVA (Class Variance Authority), with key differences:
| Feature | CVA | css-variants |
|---|---|---|
| Base styles | First argument | base property |
| Compound class key | class | className |
| Bundle size | ~2KB | ~1KB |
| Slot variants | Not built-in | scv() function |
| Style variants | Not built-in | sv() and ssv() functions |
| Performance | Baseline | 3-7x faster (compound variants) |
See the Migration Guide for detailed migration steps.
css-variants offers several advantages over CVA:
If you’re starting a new project or care about performance, css-variants is the better choice.
css-variants offers significant improvements over tailwind-variants:
sv, ssv) for inline CSSChoose tailwind-variants if you need:
extend propertyThe best CSS variant library depends on your priorities:
| Priority | Best Choice | Why |
|---|---|---|
| Performance | css-variants | 3-11x faster than alternatives |
| Bundle size | css-variants | ~1KB, smallest available |
| Ecosystem/tutorials | CVA | More established, more examples |
| Built-in Tailwind merge | tailwind-variants | Automatic class conflict resolution |
For most projects, css-variants is the best choice due to its combination of performance, small size, and comprehensive features.
Yes, css-variants is a drop-in replacement for CVA. The migration is straightforward:
import { cva } from 'class-variance-authority'import { cv } from 'css-variants'
const button = cva('base-classes', {const button = cv({ base: 'base-classes', variants: { /* unchanged */ }, compoundVariants: [ { color: 'primary', class: 'extra' } { color: 'primary', className: 'extra' } ],})Absolutely yes! css-variants is framework-agnostic and works with any CSS approach:
// Vanilla CSSconst button = cv({ base: 'btn', variants: { color: { primary: 'btn-primary', secondary: 'btn-secondary', }, },})
// CSS Modulesimport styles from './Button.module.css'
const button = cv({ base: styles.button, variants: { color: { primary: styles.primary, secondary: styles.secondary, }, },})Yes! css-variants works excellently with React:
import { cv } from 'css-variants'
const button = cv({ base: 'px-4 py-2 rounded font-medium', variants: { variant: { primary: 'bg-blue-600 text-white', secondary: 'bg-gray-200 text-gray-800', }, },})
// Extract variant types for propstype ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & Parameters<typeof button>[0]
function Button({ variant, className, ...props }: ButtonProps) { return <button className={button({ variant, className })} {...props} />}Yes! css-variants integrates smoothly with Vue 3:
<script setup lang="ts">import { cv } from 'css-variants'
const button = cv({ base: 'px-4 py-2 rounded font-medium', variants: { variant: { primary: 'bg-blue-600 text-white', secondary: 'bg-gray-200 text-gray-800', }, },})
defineProps<{ variant?: 'primary' | 'secondary' }>()</script>
<template> <button :class="button({ variant })"> <slot /> </button></template>Yes! css-variants works with Svelte:
<script lang="ts"> import { cv } from 'css-variants'
const button = cv({ base: 'px-4 py-2 rounded font-medium', variants: { variant: { primary: 'bg-blue-600 text-white', secondary: 'bg-gray-200 text-gray-800', }, }, })
export let variant: 'primary' | 'secondary' = 'primary'</script>
<button class={button({ variant })}> <slot /></button>| Function | Output Type | Use Case |
|---|---|---|
cv() | string | Single-element components (buttons, badges) |
scv() | { [slot]: string } | Multi-slot components (cards, modals, dropdowns) |
sv() | CSSProperties | Single-element inline styles |
ssv() | { [slot]: CSSProperties } | Multi-slot inline styles |
// cv - returns stringbutton({ color: 'primary' }) // => 'btn btn-primary'
// scv - returns object of stringscard({ variant: 'default' }) // => { root: '...', header: '...', body: '...' }
// sv - returns style objectbox({ size: 'lg' }) // => { padding: '24px', borderRadius: '8px' }
// ssv - returns object of style objectstooltip({ placement: 'top' }) // => { root: {...}, arrow: {...} }Use sv() (style variants) when you need inline CSS styles instead of class names:
width: ${value}px that can’t be utility classes--color: ${color}import { sv } from 'css-variants'
const dynamicBox = sv({ base: { display: 'flex' }, variants: { size: { sm: { width: '100px', height: '100px' }, md: { width: '200px', height: '200px' }, }, },})
// Use with style prop<div style={dynamicBox({ size: 'md' })} />Use scv() (slot class variants) for components with multiple styled elements:
import { scv } from 'css-variants'
const card = scv({ slots: ['root', 'header', 'content', 'footer'], base: { root: 'rounded-lg border', header: 'p-4 border-b', content: 'p-4', footer: 'p-4 border-t', }, variants: { variant: { default: { root: 'border-gray-200' }, primary: { root: 'border-blue-200' }, }, },})
// Usageconst classes = card({ variant: 'primary' })
// In JSX<div className={classes.root}> <header className={classes.header}>Title</header> <div className={classes.content}>Content</div> <footer className={classes.footer}>Footer</footer></div>Use tailwind-merge with a custom class resolver:
import { cv, cx } from 'css-variants'import { twMerge } from 'tailwind-merge'
const button = cv({ base: 'px-4 py-2', variants: { size: { lg: 'px-6 py-3', // Would conflict with base without twMerge }, }, classNameResolver: (...args) => twMerge(cx(...args)),})See the Tailwind Integration Guide for more details.
Use Tailwind’s responsive prefixes in your variant classes:
const container = cv({ variants: { size: { sm: 'max-w-screen-sm px-4', md: 'max-w-screen-md px-6 sm:px-8', lg: 'max-w-screen-lg px-8 sm:px-10 lg:px-12', }, },})For different variants at different breakpoints, combine with cx:
<div className={cx( container({ size: 'sm' }), 'md:max-w-screen-md lg:max-w-screen-lg')}>Two approaches:
1. Use Tailwind’s dark: prefix:
const card = cv({ base: 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white',})2. Create explicit theme variants:
const card = cv({ variants: { theme: { light: 'bg-white text-gray-900', dark: 'bg-gray-800 text-white', }, },})@apply?Yes, but we recommend using variants instead of @apply for better tree-shaking and smaller CSS bundles:
/* Works but not recommended */.btn-primary { @apply bg-blue-600 text-white hover:bg-blue-700;}/* Recommended */const button = cv({ variants: { color: { primary: 'bg-blue-600 text-white hover:bg-blue-700', }, },})Use TypeScript’s Parameters utility type:
const button = cv({ variants: { color: { primary: '...', secondary: '...' }, size: { sm: '...', md: '...', lg: '...' }, },})
type ButtonVariants = Parameters<typeof button>[0]// => { color?: 'primary' | 'secondary', size?: 'sm' | 'md' | 'lg', className?: ClassValue }Use TypeScript utility types:
type ButtonVariants = Parameters<typeof button>[0]
type RequiredColor = Omit<ButtonVariants, 'color'> & Required<Pick<ButtonVariants, 'color'>>JavaScript object keys must be strings or symbols. We use 'true' and 'false' as keys, but TypeScript infers the prop types as actual booleans:
const toggle = cv({ variants: { enabled: { true: 'bg-blue-600', // String key false: 'bg-gray-200', // String key }, },})
// Props accept actual booleanstoggle({ enabled: true }) // Workstoggle({ enabled: 'true' }) // TypeScript errorYes! Use cx to compose:
import { cv, cx } from 'css-variants'
const baseButton = cv({ base: 'rounded font-medium', variants: { size: { sm: 'px-3 py-1.5 text-sm', md: 'px-4 py-2 text-base', }, },})
const primaryButton = (props) => cx( baseButton(props), 'bg-blue-600 text-white hover:bg-blue-700')Or extend by using one variant as the base of another:
const iconButton = cv({ base: baseButton({ size: 'md' }), variants: { rounded: { default: 'rounded-lg', full: 'rounded-full', }, },})Define reusable configuration objects:
const sharedVariants = { size: { sm: 'text-sm px-2 py-1', md: 'text-base px-4 py-2', lg: 'text-lg px-6 py-3', },} as const
const button = cv({ base: 'rounded font-medium', variants: { ...sharedVariants, color: { primary: '...', secondary: '...' }, },})
const badge = cv({ base: 'rounded-full', variants: { ...sharedVariants, color: { info: '...', warning: '...' }, },})This happens when variants have conflicting classes. Use tailwind-merge:
import { twMerge } from 'tailwind-merge'
const button = cv({ base: 'px-4', variants: { size: { lg: 'px-6' }, // Without twMerge: 'px-4 px-6' }, classNameResolver: (...args) => twMerge(cx(...args)),})Make sure you’re:
as const on the config object (it breaks inference)// Goodconst button = cv({ variants: { color: { primary: '...', secondary: '...' }, },})
// Bad: Separate variable loses type inferenceconst variants = { color: { primary: '...', secondary: '...' },}const button = cv({ variants })Make sure you’re:
slots arraybase and variantsconst card = scv({ slots: ['root', 'header', 'content'], // Must list all slots base: { root: '...', header: '...', content: '...', },})
const { root, header, content } = card({ variant: 'default' })css-variants is the fastest CSS variant library available:
| vs CVA | vs tailwind-variants |
|---|---|
| 3-4x faster (compound variants) | 5-7x faster (compound variants) |
| 2-7x faster (complex components) | 10-11x faster (complex components) |
See detailed benchmarks.
css-variants achieves superior performance through:
css-variants is ~1KB minified + gzipped — the smallest variant library available:
| Library | Bundle Size |
|---|---|
| css-variants | ~1KB |
| CVA | ~2KB |
| tailwind-variants | ~5KB |
You can also use selective imports for even smaller bundles:
import { cv } from 'css-variants/cv' // Only cv functionimport { scv } from 'css-variants/scv' // Only scv function