gentleduck/variants
A lightweight, type-safe utility for generating class names from variant configurations. Designed to be fast, ergonomic, and easy to adopt in both small components and large design systems.
Philosophy
gentleduck/variants maps component variant props (size, color, state) to CSS classes with compile-time type checks. It replaces ad-hoc conditional class logic with a declarative schema that catches invalid combinations before the build runs.
Installation
npm install @gentleduck/variants
npm install @gentleduck/variants
Usage
import { cva } from '@gentleduck/variants'
const button = cva('btn', {
variants: {
size: { sm: 'px-2 py-1 text-sm', md: 'px-4 py-2', lg: 'px-6 py-3' },
tone: { default: 'bg-blue-500 text-white', subtle: 'bg-gray-100 text-gray-800' }
},
defaultVariants: { size: 'md', tone: 'default' }
})
// Use it in JSX
<button className={button({ size: 'lg' })}>Click</button>import { cva } from '@gentleduck/variants'
const button = cva('btn', {
variants: {
size: { sm: 'px-2 py-1 text-sm', md: 'px-4 py-2', lg: 'px-6 py-3' },
tone: { default: 'bg-blue-500 text-white', subtle: 'bg-gray-100 text-gray-800' }
},
defaultVariants: { size: 'md', tone: 'default' }
})
// Use it in JSX
<button className={button({ size: 'lg' })}>Click</button>The output className is deduplicated and deterministic. Repeated calls with identical props are memoized.
Core concepts
- base - classes always applied.
- variants - named axes of variation, such as
sizeortone. - defaultVariants - values used when a prop is omitted.
- compoundVariants - classes added when multiple variant selections match.
- ClassValue - accepts strings, arrays, objects, and nested combinations.
- Memoization - results are cached by a deterministic key.
How it works
API reference
// Create a factory
const fn = cva(baseOrOptions, maybeOptions?)
// Call it to get a class string
fn(props?) // returns string// Create a factory
const fn = cva(baseOrOptions, maybeOptions?)
// Call it to get a class string
fn(props?) // returns stringImportant types: Variants.VariantProps<typeof fn>, Variants.Props<TVariants>,
Variants.ClassValue.
For complete signatures and the TypeScript definitions, see the Types Reference section at the bottom.
Examples
1) Button component
import { cva, type Variants } from '@gentleduck/variants'
import { cn } from '@/lib/utils'
const buttonVariants = cva('inline-flex items-center justify-center rounded', {
variants: {
variant: {
default: 'bg-primary text-white hover:bg-primary/90',
ghost: 'bg-transparent hover:bg-accent'
},
size: { sm: 'h-8 px-3', md: 'h-10 px-4', lg: 'h-12 px-6' }
},
defaultVariants: { variant: 'default', size: 'md' }
})
type ButtonProps = Variants.VariantProps<typeof buttonVariants> & React.ButtonHTMLAttributes<HTMLButtonElement>
export function Button({ className, variant, size, ...props }: ButtonProps) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
}import { cva, type Variants } from '@gentleduck/variants'
import { cn } from '@/lib/utils'
const buttonVariants = cva('inline-flex items-center justify-center rounded', {
variants: {
variant: {
default: 'bg-primary text-white hover:bg-primary/90',
ghost: 'bg-transparent hover:bg-accent'
},
size: { sm: 'h-8 px-3', md: 'h-10 px-4', lg: 'h-12 px-6' }
},
defaultVariants: { variant: 'default', size: 'md' }
})
type ButtonProps = Variants.VariantProps<typeof buttonVariants> & React.ButtonHTMLAttributes<HTMLButtonElement>
export function Button({ className, variant, size, ...props }: ButtonProps) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
}2) Compound variants for a card
const card = cva('rounded border p-4', {
variants: {
tone: { default: 'bg-white', success: 'bg-green-50', danger: 'bg-red-50' },
size: { md: 'p-4', lg: 'p-6' },
elevated: { true: 'shadow-lg', false: 'shadow-none' }
},
compoundVariants: [
{ tone: 'danger', elevated: true, className: 'ring-1 ring-red-200' },
{ size: 'lg', tone: ['default', 'success'], className: 'font-semibold' }
]
})const card = cva('rounded border p-4', {
variants: {
tone: { default: 'bg-white', success: 'bg-green-50', danger: 'bg-red-50' },
size: { md: 'p-4', lg: 'p-6' },
elevated: { true: 'shadow-lg', false: 'shadow-none' }
},
compoundVariants: [
{ tone: 'danger', elevated: true, className: 'ring-1 ring-red-200' },
{ size: 'lg', tone: ['default', 'success'], className: 'font-semibold' }
]
})3) Passing conditional runtime classes
const classes = card({ tone: 'success', className: [{ 'animate-pulse': isLoading }, custom] })const classes = card({ tone: 'success', className: [{ 'animate-pulse': isLoading }, custom] })Benchmarks
Real bundle size and runtime performance compared to class-variance-authority, tailwind-variants, and clsx:
Run bun run benchmark in packages/duck-variants to regenerate. Data in public/data/benchmarks/variants.json.
Best practices
- Use
defaultVariantsfor the common case to reduce caller verbosity. - Use
compoundVariantsto encode rules that would otherwise repeat across components. - Group classes with arrays for clearer organization in large variant maps.
- Pass runtime state via
classNameas an object or array ({ 'is-loading': isLoading }). - Results are memoized, so calling the CVA function inside list renderers is safe.
Troubleshooting & FAQ
Migration from class-variance-authority
- The API is close enough that most simple uses are drop-in.
- Type inference is stricter; some loose usages may need small adjustments.
- Memoization behavior differs. Test hot paths if you relied on CVA internals.
Types reference
All types are exported under the Variants namespace from @gentleduck/variants.
1. Variant Params (Variants.VariantParams)
Maps each variant key to a selected value or array of values.
Accepts either a single key or a ReadonlyArray of keys for each variant, or null to clear a default.
export type VariantParams<TVariants extends VariantDefinitions> = {
readonly [K in keyof TVariants]?: keyof TVariants[K] | ReadonlyArray<keyof TVariants[K]> | null
}export type VariantParams<TVariants extends VariantDefinitions> = {
readonly [K in keyof TVariants]?: keyof TVariants[K] | ReadonlyArray<keyof TVariants[K]> | null
}2. CVA Options (Variants.Options)
Options for creating a CVA function, without the base classes.
| Field | Description |
|---|---|
variants | Base mapping of variants -> classes. |
defaultVariants | Defaults applied when no value is passed. |
compoundVariants | Conditional styles that apply when multiple variants match. |
export type Options<TVariants extends VariantDefinitions> = {
readonly variants?: TVariants
readonly defaultVariants?: VariantParams<TVariants>
readonly compoundVariants?: ReadonlyArray<
VariantParams<TVariants> & {
readonly class?: ClassValue
readonly className?: ClassValue
}
>
}export type Options<TVariants extends VariantDefinitions> = {
readonly variants?: TVariants
readonly defaultVariants?: VariantParams<TVariants>
readonly compoundVariants?: ReadonlyArray<
VariantParams<TVariants> & {
readonly class?: ClassValue
readonly className?: ClassValue
}
>
}3. CVA Config (Variants.Config)
Full configuration accepted by cva(): Options plus optional base classes.
export type Config<TVariants extends VariantDefinitions> = Options<TVariants> & {
readonly base?: ClassValue
}export type Config<TVariants extends VariantDefinitions> = Options<TVariants> & {
readonly base?: ClassValue
}4. CVA Props (Variants.Props)
Props that a CVA-generated function accepts.
Includes both variant selections and optional class/className overrides.
export type Props<TVariants extends VariantDefinitions> = VariantParams<TVariants> & {
readonly className?: ClassValue
readonly class?: ClassValue
}export type Props<TVariants extends VariantDefinitions> = VariantParams<TVariants> & {
readonly className?: ClassValue
readonly class?: ClassValue
}5. Variant Props (Variants.VariantProps)
Utility type to extract only the variant-related props from a CVA function, omitting class and className.
export type VariantProps<T> = T extends (props?: infer P) => string
? Omit<NonNullable<P>, 'class' | 'className'>
: neverexport type VariantProps<T> = T extends (props?: infer P) => string
? Omit<NonNullable<P>, 'class' | 'className'>
: never6. Infer (Variants.Infer)
Infers the variants field from an Options object. Useful when you want to extract the variant map from a config constant.
export type Infer<T extends Options<VariantDefinitions>> = T['variants']export type Infer<T extends Options<VariantDefinitions>> = T['variants']7. Class Utility Types
Helper types that define the shape of class names:
| Type | Purpose |
|---|---|
Variants.ClassDictionary | Conditional { 'class': boolean | null | undefined }. Readonly. |
Variants.ClassArray | Readonly nested arrays of class values. |
Variants.ClassValue | Union of everything accepted as a class. |
Variants.ClassPrimitive | Primitive scalar values (string, number, bigint, boolean, null, undefined). |
export type ClassPrimitive = string | number | bigint | boolean | null | undefined
export type ClassDictionary = Readonly<Record<string, boolean | null | undefined>>
export type ClassArray = ReadonlyArray<ClassValue>
export type ClassValue = ClassPrimitive | ClassDictionary | ClassArrayexport type ClassPrimitive = string | number | bigint | boolean | null | undefined
export type ClassDictionary = Readonly<Record<string, boolean | null | undefined>>
export type ClassArray = ReadonlyArray<ClassValue>
export type ClassValue = ClassPrimitive | ClassDictionary | ClassArray