react-class-variants is a recipe-first, type-safe API for composing CSS classes in React components.
This documentation describes the current react-class-variants v2.x surface.
If you are looking for the legacy react-tailwind-variants v1.x docs, start
with the legacy v1 docs entrypoint.
The current v2 alpha surface is built around:
- one adaptive
recipe()primitive - one React builder,
styled(), exposed throughdefineConfig() - explicit view-consumed component props through
defineViewProps() - explicit
renderpolymorphism - explicit merge configuration through
defineConfig() - a dedicated
coresubpath for recipe-only modules
- Package name:
react-class-variants - Current line:
2.0.0-alpha.x - Recommended install:
react-class-variants@alpha - Runtime requirements: Node.js
20.19+and React19 - Module format: ESM-only
pnpm add react-class-variants@alphaOptional Tailwind conflict resolution:
pnpm add tailwind-merge| Need | Use |
|---|---|
| Compute one root class string | recipe(input) |
| Compute slot class strings | recipe(input).slotName() |
| Split variant props from a full prop bag | recipe.resolve(input, options) |
| Build a React component from a recipe | const { styled } = defineConfig() |
Declare component props consumed by view |
defineViewProps<T>(...keys) |
| Share merge or validate behavior | defineConfig(options) |
| Keep typed config objects around | defineRecipeConfig(config) |
| Avoid React runtime imports | react-class-variants/core |
Four rules explain most of the package:
recipe()becomes a root recipe when you usebase, and a slotted recipe when you useslots.resolve()is the full-prop-bag API. Direct recipe calls stay variant-oriented.- Get
styled()fromdefineConfig(); it accepts intrinsic bases and custom React component bases. - Slotted recipes require
view, andrenderis opt-in throughwithRender: truefor intrinsic bases only.
import { defineConfig, recipe } from 'react-class-variants';
const { styled } = defineConfig();
const buttonRecipe = recipe({
base: 'inline-flex items-center justify-center rounded-md font-medium transition',
variants: {
tone: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
ghost: 'bg-transparent text-slate-900 hover:bg-slate-100',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-base',
},
},
defaultVariants: {
tone: 'primary',
size: 'md',
},
});
export const Button = styled('button', buttonRecipe);Usage:
<Button tone="ghost" size="sm" type="button">
Cancel
</Button>Direct recipe calls stay available when you only need a class string:
buttonRecipe({ tone: 'primary', className: 'w-full' });Use defineRecipeConfig() when you want the config itself to stay available as a typed source of truth, for example to power Storybook controls, docs, or test fixtures from the same declared variants:
import { defineRecipeConfig, recipe } from 'react-class-variants';
export const badgeConfig = defineRecipeConfig({
base: 'inline-flex items-center rounded-full font-medium',
variants: {
tone: {
info: 'bg-sky-100 text-sky-800',
success: 'bg-emerald-100 text-emerald-800',
danger: 'bg-red-100 text-red-800',
},
size: {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-sm',
},
},
defaultVariants: {
tone: 'info',
size: 'md',
},
});
export const badgeRecipe = recipe(badgeConfig);
export const badgeToneOptions = Object.keys(badgeConfig.variants.tone);defineRecipeConfig() is a zero-cost typed helper. Reach for it when you keep a config object in a variable and want defaultVariants completions plus exact key/value checks. In v2, recipe instances do not expose a runtime .config property, so this is the intended way to keep config data around.
If you use the Tailwind CSS IntelliSense extension, register the v2 recipe
helpers in tailwindCSS.classFunctions so completions, hovers, and linting
work inside recipe(...) and defineRecipeConfig(...) calls:
{
"editor.quickSuggestions": {
"strings": "on"
},
"tailwindCSS.classFunctions": ["recipe", "defineRecipeConfig"]
}This is the setting that covers class strings in base, slots, variant
branches, and compoundVariants[].className inside JavaScript and TypeScript
config objects.
If you rename a configured factory, add that alias too:
const { recipe: uiRecipe } = defineConfig();{
"tailwindCSS.classFunctions": ["recipe", "defineRecipeConfig", "uiRecipe"]
}Tailwind CSS IntelliSense matches function names, so wrapper or alias names must be listed explicitly. This still depends on the extension detecting your Tailwind project normally.
Use slots when different parts of the component need different classes, then render them through a view component:
import { defineConfig, recipe } from 'react-class-variants';
const { styled } = defineConfig();
const buttonRecipe = recipe({
slots: {
root: 'inline-flex items-center gap-2 rounded-md font-medium',
icon: 'size-4',
label: 'truncate',
},
variants: {
tone: {
primary: {
root: 'bg-blue-600 text-white',
icon: 'text-blue-100',
},
ghost: {
root: 'bg-transparent text-slate-900',
icon: 'text-slate-500',
},
},
},
defaultVariants: {
tone: 'primary',
},
});
function ButtonView({ host, classes }) {
return host.render({
children: (
<>
<span aria-hidden="true" className={classes.icon()} />
<span className={classes.label()}>{host.children}</span>
</>
),
});
}
const Button = styled('button', buttonRecipe, {
withRender: true,
view: ButtonView,
});Usage:
<Button tone="ghost" className="w-full">
Cancel
</Button>
<Button
tone="primary"
slotClassNames={{
icon: 'text-red-500',
label: 'uppercase',
}}
>
Save
</Button>
<Button tone="primary" render={<a href="/docs" />}>
Docs
</Button>External non-host slot overrides use slotClassNames:
const slots = buttonRecipe({
tone: 'primary',
slotClassNames: {
icon: 'text-red-500',
},
});
const resolved = buttonRecipe.resolve({
tone: 'primary',
className: 'w-full',
slotClassNames: {
label: 'uppercase',
},
id: 'save',
});Key points:
viewis required for slotted recipesviewis a normal React component, so hooks and context work inside it- prefer a named component such as
ButtonViewwhen you use hooks - use
defineViewProps()when aviewneeds component-level props such asicon,startIcon,endIcon, orshortcut; those props stay onhost.propsand are stripped before the rendered host receives its props - use
classes.slotName()for the most direct slot lookup inview classesis still an enumerable slot render map and may be safely destructured when that reads better- top-level
slotClassNamesapplies to matching slots, while top-levelclassNamestill routes only to the host slot slotClassNameskeys should match declared slot namesslotClassNamesis consumed before props are forwarded, while local slot-functionclassNamestill wins for that one slot call- external component
classNameis routed automatically to the host slot - if the recipe has no
rootslot, providehostSlotwith one of the declared slot names - call
host.render(...)directly as a method - do not destructure
renderfromhost - this is intentional: keeping
host.rendermethod-shaped avoids allocating one extra function perviewrender
Inline view functions are still fine for trivial cases. A named component is
easier for hook linting, React DevTools, and stack traces once the view
starts using hooks.
Use recipe.resolve() when you need class resolution plus a full prop bag:
- wrapper components
- headless abstractions
- host prop aliasing such as
propAliases: { size: 'htmlSize' } - explicit forwarding of resolved variant values with
forwardProps
Example:
import type { ComponentPropsWithoutRef } from 'react';
import { recipe, type VariantProps } from 'react-class-variants';
const inputRecipe = recipe({
base: 'block w-full rounded-md border',
variants: {
size: {
sm: 'h-9 px-3 text-sm',
md: 'h-10 px-4 text-base',
},
invalid: {
true: 'border-red-500 ring-1 ring-red-500',
},
},
defaultVariants: {
size: 'md',
invalid: false,
},
});
type InputProps = Omit<ComponentPropsWithoutRef<'input'>, 'size'> &
VariantProps<typeof inputRecipe> & {
htmlSize?: number;
};
export function Input(props: InputProps) {
const resolved = inputRecipe.resolve(props, {
propAliases: {
size: 'htmlSize',
},
});
return (
<input
{...resolved.resolvedProps}
aria-invalid={resolved.variants.invalid || undefined}
/>
);
}Usage:
<Input size="sm" htmlSize={20} invalid type="email" />What resolve() gives you here:
resolved.resolvedPropsis the host-ready prop bag, including the mergedclassNamepropAliaseslets the public API accepthtmlSizewhileresolvedPropsreceives the nativesizepropresolved.variantsgives you the effective variant selection after defaults and boolean fallbacks- for slot recipes,
resolve()returnsslotsplusresolvedPropsinstead of one rootclassName - for slot recipes, top-level
slotClassNamesapplies to matching slots and is consumed before props are forwarded
Alias names must not collide with existing host props, reserved React public props, or declared variant keys.
Use defineConfig() when you want one configured factory for many recipes:
import { defineConfig } from 'react-class-variants';
import { twMerge } from 'tailwind-merge';
export const { recipe, styled } = defineConfig({
merge: twMerge,
});Supported options:
type SystemOptions = {
merge?: (className: string) => string;
validate?: 'never' | 'always';
};Default root and core imports are lean and process-less safe. Use
defineConfig({ validate: 'always' }) when you want checked runtime behavior
for a shared factory.
Package root also exports a few low-level helpers for wrappers and polymorphic components:
import {
hasOwnProperty,
mergeProps,
mergeRefs,
useMergeRefs,
} from 'react-class-variants';hasOwnProperty(object, key)is a typed own-property guard and is also available fromreact-class-variants/coremergeProps(base, overrides)concatenatesclassName, shallow-mergesstyle, composes React event handlers with override-first ordering, and replaces other props with the override valuemergeRefs(...refs)creates a merged ref callback for non-hook contexts such ascloneElement()or conditional branchesuseMergeRefs(...refs)is the memoized hook form for React components
Most users will work with these package-root APIs from react-class-variants:
recipe()defineConfig()for configuredrecipe()andstyled()defineRecipeConfig()defineViewProps()- public core and React types
Package root also exports mergeProps, mergeRefs, useMergeRefs, and
hasOwnProperty for lower-level integration work.
For recipe-only modules, the primary react-class-variants/core APIs are:
recipe()- core
defineConfig() defineRecipeConfig()- core recipe types
Use the core subpath when you want recipe modules without React runtime helpers.
If you are new to the package, use this order:
- Recipes and components guide for the main usage patterns
- API reference for exact shapes, options, and runtime rules
- Migration guide if you are moving from
react-tailwind-variants
Other docs: