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.
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>
This will output a deduplicated, deterministic className
string and is
memoized for repeated calls with identical props.
Core concepts (what you need to know)
- base - classes always applied.
- variants - named axes of variation (e.g.,
size
,tone
). - defaultVariants - sensible defaults when a prop is omitted.
- compoundVariants - conditional rules that add classes when multiple variant selections match simultaneously.
- ClassValue - flexible input allowing strings, arrays, objects, and nested combinations.
- Memoization - results are cached by a deterministic key so identical calls are fast.
API reference (concise)
// 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: VariantProps<typeof fn>
, CvaProps<TVariants>
,
ClassValue
.
For complete signatures and the TypeScript definitions, see the Types Reference section at the bottom.
Examples (practical & actionable)
1) Button component (production-ready)
import { cva, type VariantProps } 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 = 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 VariantProps } 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 = 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] })
Best practices & performance tips
- Prefer defaultVariants for the most common case - it reduces caller verbosity and improves readability.
- Use compoundVariants to centrally encode rules that would otherwise be repeated across components.
- Group classes with arrays for clearer organization in large variant maps.
- Pass runtime state via
className
as an object or array (e.g.{ 'is-loading': isLoading }
). - Memoization: because results are cached, calling the CVA function in hot render paths (e.g., inside list renderers) is safe and fast.
Troubleshooting & FAQ
Q: Why aren't my compoundVariants applying?
A: Ensure the compound object keys exactly match your variant names and that
values are either the exact allowed value or an array of allowed values.
Also confirm you did not pass 'unset'
(used to explicitly disable variant
application).
Q: My classes are out of order or duplicated.
A: The library deduplicates tokens and preserves the first-seen ordering where possible. If ordering matters (rare), restructure base vs variant classes so base-critical tokens appear first.
Q: How do I completely disable a variant's default?
A: Pass the literal string 'unset'
for that variant: e.g.
button({ size: 'unset' })
will skip size classes.
Migration notes (from class-variance-authority)
- The API is intentionally similar - most simple uses will be drop-in.
- This library enforces stricter type inference; some loose usages that previously passed TypeScript may need small type adjustments.
- Performance and memoization behavior differ - test critical paths if you relied on CVA internals.
Types reference
1. Variant Params
This defines the mapping of variant names (like size
, intent
, etc.) to their possible values.
It accepts either a single key or an array of keys for each variant.
export type VariantParams<
TVariants extends Record<string, Record<string, string | string[]>>
> = {
[K in keyof TVariants]?: keyof TVariants[K] | Array<keyof TVariants[K]>
}
export type VariantParams<
TVariants extends Record<string, Record<string, string | string[]>>
> = {
[K in keyof TVariants]?: keyof TVariants[K] | Array<keyof TVariants[K]>
}
2. CVA Configuration
Defines how to configure a CVA function, including:
variants
: The base mapping of variants → classesdefaultVariants
: Defaults applied when no value is passedcompoundVariants
: Conditional styles that apply when multiple variants match
export interface VariantsOptions<
TVariants extends Record<string, Record<string, string | string[]>>
> {
variants: TVariants
defaultVariants?: VariantParams<TVariants>
compoundVariants?: Array<
VariantParams<TVariants> & {
class?: ClassValue
className?: ClassValue
}
>
}
export interface VariantsOptions<
TVariants extends Record<string, Record<string, string | string[]>>
> {
variants: TVariants
defaultVariants?: VariantParams<TVariants>
compoundVariants?: Array<
VariantParams<TVariants> & {
class?: ClassValue
className?: ClassValue
}
>
}
3. CVA Props
Props that a CVA-generated function accepts.
Includes both variant selections and optional class
/className
overrides.
export type CvaProps<
TVariants extends Record<string, Record<string, string | string[]>>
> = VariantParams<TVariants> & {
className?: ClassValue
class?: ClassValue
}
export type CvaProps<
TVariants extends Record<string, Record<string, string | string[]>>
> = VariantParams<TVariants> & {
className?: ClassValue
class?: ClassValue
}
4. Variant Props
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
? {
[K in keyof P as K extends 'class' | 'className' ? never : K]: P[K]
}
: never
export type VariantProps<T> = T extends (props?: infer P) => string
? {
[K in keyof P as K extends 'class' | 'className' ? never : K]: P[K]
}
: never
5. Class Utility Types
Helper types that define the shape of class names:
ClassDictionary
: conditional{ 'class': boolean }
ClassArray
: nested arrays of class valuesClassValue
: union of everything accepted as a class
export type ClassDictionary = Record<string, boolean | undefined>
export type ClassArray = ClassValue[]
export type ClassValue =
| string
| number
| boolean
| ClassDictionary
| ClassArray
export type ClassDictionary = Record<string, boolean | undefined>
export type ClassArray = ClassValue[]
export type ClassValue =
| string
| number
| boolean
| ClassDictionary
| ClassArray