Skip to main content

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 size or tone.
  • 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

Loading diagram...

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 string

Important 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 defaultVariants for the common case to reduce caller verbosity.
  • Use compoundVariants to encode rules that would otherwise repeat across components.
  • Group classes with arrays for clearer organization in large variant maps.
  • Pass runtime state via className as 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.

variants.types.ts
export type VariantParams<TVariants extends VariantDefinitions> = {
  readonly [K in keyof TVariants]?: keyof TVariants[K] | ReadonlyArray<keyof TVariants[K]> | null
}
variants.types.ts
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.

FieldDescription
variantsBase mapping of variants -> classes.
defaultVariantsDefaults applied when no value is passed.
compoundVariantsConditional styles that apply when multiple variants match.
variants.types.ts
export type Options<TVariants extends VariantDefinitions> = {
  readonly variants?: TVariants
  readonly defaultVariants?: VariantParams<TVariants>
  readonly compoundVariants?: ReadonlyArray<
    VariantParams<TVariants> & {
      readonly class?: ClassValue
      readonly className?: ClassValue
    }
  >
}
variants.types.ts
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.

variants.types.ts
export type Config<TVariants extends VariantDefinitions> = Options<TVariants> & {
  readonly base?: ClassValue
}
variants.types.ts
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.

variants.types.ts
export type Props<TVariants extends VariantDefinitions> = VariantParams<TVariants> & {
  readonly className?: ClassValue
  readonly class?: ClassValue
}
variants.types.ts
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.

variants.types.ts
export type VariantProps<T> = T extends (props?: infer P) => string
  ? Omit<NonNullable<P>, 'class' | 'className'>
  : never
variants.types.ts
export type VariantProps<T> = T extends (props?: infer P) => string
  ? Omit<NonNullable<P>, 'class' | 'className'>
  : never

6. Infer (Variants.Infer)

Infers the variants field from an Options object. Useful when you want to extract the variant map from a config constant.

variants.types.ts
export type Infer<T extends Options<VariantDefinitions>> = T['variants']
variants.types.ts
export type Infer<T extends Options<VariantDefinitions>> = T['variants']

7. Class Utility Types

Helper types that define the shape of class names:

TypePurpose
Variants.ClassDictionaryConditional { 'class': boolean | null | undefined }. Readonly.
Variants.ClassArrayReadonly nested arrays of class values.
Variants.ClassValueUnion of everything accepted as a class.
Variants.ClassPrimitivePrimitive scalar values (string, number, bigint, boolean, null, undefined).
variants.types.ts
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 | ClassArray
variants.types.ts
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 | ClassArray