This document covers the primary user-facing v2 alpha APIs and public helpers:
recipe(), recipe.resolve(), defineConfig().styled, defineConfig(),
defineRecipeConfig(), defineViewProps(), view, render, and the
exported utilities.
Use this page as a reference, not as a tutorial. For guided examples and recommended patterns, start with the recipes and components guide.
import {
defineConfig,
defineRecipeConfig,
defineViewProps,
recipe,
} from 'react-class-variants';
const { styled } = defineConfig();For recipe-only modules, the primary react-class-variants/core APIs covered
here are:
recipe()defineConfig()(returns{ recipe })defineRecipeConfig()variantNames()andvariantOptions()hasOwnProperty()- core recipe types
recipe() is the canonical styling primitive.
Use base for root recipes:
const badgeRecipe = recipe({
base: 'inline-flex rounded-full',
variants: {
tone: {
info: 'bg-sky-100',
danger: 'bg-rose-100',
},
},
});Root compound variants use a flat selector object plus one final className:
const badgeRecipe = recipe({
base: 'inline-flex rounded-full',
variants: {
tone: {
info: 'bg-sky-100',
danger: 'bg-rose-100',
},
size: {
sm: 'px-2 py-1 text-xs',
md: 'px-3 py-1.5 text-sm',
},
},
compoundVariants: [
{
tone: ['info', 'danger'],
size: 'sm',
className: 'tracking-wide',
},
],
defaultVariants: {
size: 'md',
},
});Compound selector semantics:
- an explicitly
undefinedselector value is treated as if the key were absent, in both runtimes (cva instead matches such a selector only while the variant resolves toundefined— never, once a default exists) undefinedentries inside array selectors are filtered out; an array that ends up empty never matches- a selector key that is not a declared variant throws at recipe creation with
validate: 'always'; the lean runtime drops that whole compound so it never applies (matching cva/tailwind-variants semantics)
Use slots for slotted recipes:
const buttonRecipe = recipe({
slots: {
root: 'inline-flex items-center gap-2',
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',
},
});Slot compound variants use the same selectors, but className becomes a slot map:
const fieldRecipe = recipe({
slots: {
label: 'text-sm',
input: 'rounded-md border',
},
variants: {
invalid: {
true: {
label: 'text-red-700',
input: 'border-red-500',
},
},
size: {
sm: {
input: 'h-9 px-3 text-sm',
},
md: {
input: 'h-10 px-4 text-base',
},
},
},
compoundVariants: [
{
invalid: true,
size: 'sm',
className: {
input: 'pr-9',
label: 'font-semibold',
},
},
],
});badgeRecipe({ tone: 'info', className: 'px-2' });Rules:
- root direct calls accept variant props plus optional
className - variants without defaults are required
- boolean variants use
"true"and"false"keys and accept boolean inputs
const slots = buttonRecipe({
tone: 'primary',
slotClassNames: {
icon: 'text-red-500',
},
});
slots.root();
slots.icon({ className: 'text-red-500' });Rules:
- top-level slot recipe calls do not accept
className - top-level slot recipe calls accept optional
slotClassNames slotClassNameskeys should match declared slot names- slot render functions accept local variant overrides plus optional
className - slot render functions are plain functions and may be safely destructured
Use resolve() when you need class resolution and a full prop bag.
The input is typed like the props of a component built from the recipe:
variant props take the recipe's options, className (and slotClassNames on
slotted recipes) takes class values, and any other prop passes through.
const result = badgeRecipe.resolve(
{
tone: 'info',
type: 'button',
className: 'w-full',
},
{
forwardProps: ['tone'],
}
);Return shape:
type RootResolveResult<TRecipe> = {
variants: ResolvedVariantProps<TRecipe>;
resolvedProps: Record<string, unknown> & {
className: string;
};
};const result = buttonRecipe.resolve(
{
tone: 'primary',
className: 'external',
slotClassNames: {
icon: 'text-red-500',
},
id: 'save',
},
{
forwardProps: ['tone'],
}
);Return shape:
type SlotResolveResult<TRecipe> = {
variants: ResolvedVariantProps<TRecipe>;
slots: {
[Slot in SlotNames<TRecipe>]: SlotRenderFunction<TRecipe>;
};
resolvedProps: Record<string, unknown>;
};For slot recipes:
resolve()does not choose a canonical slot for component-levelclassName- if
classNamewas present in the input, it stays inresolvedProps - if
slotClassNameswas present in the input, it is consumed and applied to the matching slots
type ResolveOptions = {
forwardProps?: readonly string[];
propAliases?: Record<string, string>;
};- each entry must be a declared variant key
- forwarded values reflect the effective resolved selection
- forwarded values include defaults and boolean fallbacks
Example:
const NavLink = styled(NavLinkBase, navLinkRecipe, {
forwardProps: ['active'],
});Use this when the host base needs a resolved variant value in its own props.
propAliases maps:
- key: the resolved base prop name
- value: the alternate public prop name
Example:
propAliases: {
size: 'htmlSize',
}This means:
- the public surface accepts
htmlSize resolvedPropsreceivessizehost.propsinsideviewalso receivessize- variant props are not renamed;
propAliasesonly solve collisions with base props - alias names must not collide with existing base prop names, reserved React public props, or declared variant keys
Typical styled usage:
const Input = styled('input', inputRecipe, {
propAliases: {
size: 'htmlSize',
},
});styled() is the only high-level React builder, and you get it from
defineConfig().
- intrinsic tags such as
'button','input','label','a' - custom React components
Rules:
- only intrinsic bases support
withRender - a custom base receives
renderas an ordinary prop, so components that own arenderprop (Base UI, Ark UI) keep it - slotted recipes require
view - root recipes may omit
view - custom bases should accept and forward
className,children, andrefwhen those behaviors matter
styled() returns a plain React 19 function component (FunctionComponent),
not a forwardRef object. ref is a regular prop:
- it reaches the rendered host element, or the base component as its
refprop - in a
view, it stays out ofhost.propsand is attached byhost.render(), merged with anyrefpassed tohost.render() ComponentRef<typeof Component>resolves to the base element or component instance
Component bases may take ref as a regular prop or still use forwardRef.
recipe() and styled() use no client-only hooks or state, so modules that
define recipes and styled components need no 'use client' directive and
render in React Server Components.
A view receives host and, for slotted recipes, classes, and both hold
functions. React cannot pass functions from the server to a Client Component,
so a view must render where its styled component renders: keep a
'use client' view (one that uses hooks or event state) together with the
styled() call in a client module.
const Badge = styled('span', badgeRecipe);Behavior:
- renders the base directly
- applies the resolved root class string automatically
- preserves base props and variant props on the component surface
import type { RootStyledViewProps } from 'react-class-variants';
function BadgeView({
host,
variants,
}: RootStyledViewProps<'span', typeof badgeRecipe>) {
return host.render({
'data-tone': variants.tone,
children: host.children,
});
}
const Badge = styled('span', badgeRecipe, {
view: BadgeView,
});An inline view infers its props. A named one is annotated with
RootStyledViewProps<Base, typeof recipe> or
SlotStyledViewProps<Base, typeof recipe>; pass true as a third type
argument when the component uses withRender.
import type { SlotStyledViewProps } from 'react-class-variants';
function ButtonView({
host,
classes,
variants,
}: SlotStyledViewProps<'button', typeof buttonRecipe>) {
const { icon, label } = classes;
return host.render({
'data-tone': variants.tone,
children: (
<>
<span className={icon()} />
<span className={label()}>{host.children}</span>
</>
),
});
}
const Button = styled('button', buttonRecipe, {
view: ButtonView,
});view is rendered as a normal React component. Hooks and context are allowed
inside it. When you use hooks, prefer a named component reference such as
view: ButtonView so hook linting, React DevTools, and stack traces have a
stable component name.
If the slot recipe has no root slot:
function FieldView({
host,
classes,
}: SlotStyledViewProps<'label', typeof fieldRecipe>) {
return host.render({
children: (
<>
<input className={classes.input()} />
{host.children}
</>
),
});
}
const Field = styled('label', fieldRecipe, {
hostSlot: 'label',
view: FieldView,
});type StyledOptionsCommon = {
displayName?: string;
forwardProps?: readonly string[];
propAliases?: Record<string, string>;
};Overrides the generated React component name.
By default, styled() uses Styled(<base>), for example
Styled(button) or Styled(RouterLink). For component bases the name comes
from the base's displayName, then its function name; forwardRef and memo
wrappers are unwrapped. Anonymous components fall back to Styled(Component).
Example:
const Badge = styled('span', badgeRecipe, {
displayName: 'Badge',
});Use this when you want a clearer component name in React DevTools, error stacks, or profiling output.
type RootStyledOptions = StyledOptionsCommon & {
withRender?: boolean;
view?: ComponentType<RootStyledViewProps<...>>;
viewProps?: ViewPropsDescriptor;
};viewProps requires view.
type SlotStyledOptions = StyledOptionsCommon & {
withRender?: boolean;
view: ComponentType<SlotStyledViewProps<...>>;
viewProps?: ViewPropsDescriptor;
} & (
'root' extends SlotNames<TRecipe>
? {
hostSlot?: SlotNames<TRecipe>;
}
: {
hostSlot: SlotNames<TRecipe>;
}
);If the recipe does not declare a root slot, hostSlot is required.
When you provide hostSlot, it must be one of the declared slot names.
Use defineViewProps() when a styled(..., { view }) component needs extra
public props that belong to the component surface, not to the rendered host.
This is most useful for intrinsic hosts, where leaking those props to the DOM
would be invalid.
Notes:
- list every key of
Twithtrue, as indefineViewProps<{ icon?: Icon }>({ icon: true }); a missing or unknown key is a type error, so the declared type and the consumed keys cannot drift apart defineViewProps()is a React-layer helper and is exported from the package root, not fromreact-class-variants/core- it only affects
styled(..., { view });recipe.resolve()does not consumeviewProps - it also works with custom bases when
viewshould consume props before the base receives its forwarded prop bag
Example:
import type { ReactElement } from 'react';
import { defineConfig, defineViewProps, recipe } from 'react-class-variants';
const { styled } = defineConfig();
const buttonRecipe = recipe({
slots: {
root: 'inline-flex items-center gap-2',
icon: 'size-4',
label: 'truncate',
},
});
const Button = styled('button', buttonRecipe, {
viewProps: defineViewProps<{
icon?: (props: { className?: string }) => ReactElement | null;
shortcut?: string;
}>({ icon: true, shortcut: true }),
view({ host, classes }) {
const { icon: Icon, shortcut } = host.props;
return host.render({
'data-shortcut': shortcut,
children: (
<>
{Icon ? <Icon className={classes.icon()} /> : null}
<span className={classes.label()}>{host.children}</span>
</>
),
});
},
});Behavior:
- declared keys are added to the component's public prop surface
- declared keys are available in
host.props - declared keys are consumed before
host.render()forwards props to the rendered host - declared keys must not collide with host props, reserved React public props, variant keys, or public
propAliases viewPropscompose withpropAliases,forwardProps, andwithRender;host.propsreflects the same normalized prop-routing rules beforeviewrenders
view is a real React component surface, not a callback DSL.
Guidance:
- hooks and context are allowed inside
view - prefer a named component reference such as
view: ButtonViewwhen you use hooks so hook linting, React DevTools, and stack traces keep a clear component name - inline
viewfunctions are still valid when you do not need hooks
type RootStyledViewProps<Base, TRecipe, WithRender> = {
host: HostView<Base, TRecipe, WithRender>;
variants: ResolvedVariantProps<TRecipe>;
};type SlotStyledViewProps<Base, TRecipe, WithRender> = {
host: HostView<Base, TRecipe, WithRender>;
variants: ResolvedVariantProps<TRecipe>;
classes: {
[Slot in SlotNames<TRecipe>]: SlotRenderFunction<TRecipe>;
};
};type HostView<Base, TRecipe, WithRender> = {
props: Record<string, unknown>;
className: string;
children?: ReactNode;
render(overrides?: HostRenderOverrides<Base, WithRender>): ReactNode;
};Behavior:
host.propscontains normalized pass-through props- aliased base props appear here under their resolved base names, not their public alias names
- forwarded variant keys reappear here with their resolved values
- consumed
viewPropsalso appear here and stay available toview host.classNameis already final for the rendered hosthost.render()renders the base with optional overrideshost.render({ className })appends to the resolved host class string and runsmergeover the result, so an added class wins a conflict the same way aclassNameprop doeshost.render()reuses the currenthost.childrenunless you overridechildren- declared
viewPropsare stripped before props reach the rendered host orrendertarget - for slotted recipes, external component
classNameis routed automatically to the host slot - for slotted recipes, top-level
slotClassNamesapplies to the matching slots before local slot-functionclassNameoverrides
Important note:
- call
host.render(...)directly as a method - do not destructure
renderfromhost - this is intentional: keeping
host.rendermethod-shaped avoids allocating one extra function perviewrender
Example:
function ActionView({
host,
}: SlotStyledViewProps<'button', typeof buttonRecipe>) {
return host.render({
className: 'justify-between gap-2',
children: (
<>
<span>{host.children}</span>
{host.props['data-shortcut'] ? (
<kbd>{String(host.props['data-shortcut'])}</kbd>
) : null}
</>
),
});
}For slotted views:
classesis a readonly slot render map with enumerable object semanticsObject.keys(classes),Object.entries(classes), and{ ...classes }all expose the declared slots in orderclassesmay be safely destructured- local slot overrides still recompute compounds against the merged local selection
render is available only when withRender: true and the base is intrinsic.
A custom base receives render as an ordinary prop instead (see
Supported bases).
import { defineConfig, recipe } from 'react-class-variants';
const { styled } = defineConfig();
const linkRecipe = recipe({
base: 'inline-flex items-center rounded-md font-medium',
variants: {
tone: {
primary: 'bg-blue-600 text-white',
ghost: 'bg-transparent text-slate-900',
},
},
});
const LinkButton = styled('button', linkRecipe, {
withRender: true,
});
<LinkButton tone="primary" render={<a href="/docs" />}>
Docs
</LinkButton>;Function form:
<LinkButton
tone="ghost"
render={props => <a {...props} href="/docs/api-reference" />}
>
API
</LinkButton>It accepts:
- a React element, for example
<a href="/docs" /> - a function, for example
props => <a {...props} href="/docs" />
When the render target is a React element:
classNameis appended after the resolved class string, andmergeruns over the resultstyleis shallow-merged- event handlers are composed
- refs are merged
When the render target is a function:
- its props are intentionally broad
- you always receive the resolved
className,children, andref - you receive a spread-safe generic HTML attribute bag rather than exact intrinsic resolved props
- any variants listed in
forwardPropsare included on that bag
const { recipe, styled } = defineConfig(options);Core-only usage:
import { defineConfig } from 'react-class-variants/core';
const { recipe } = defineConfig({
validate: 'always',
});Supported options:
type SystemOptions = {
cache?: boolean | { maxSize?: number };
merge?: (className: string) => string;
validate?: 'never' | 'always';
};- runs after class resolution
- applies to root results
- applies to each slot render result
The option type is exported as ValidateMode ('never' | 'always').
'always': strict validation everywhere'never': lean runtime with no validation
Both modes throw on structural errors:
- recipe configs with both
baseandslots, a slotted recipe without aslotsobject, a variant key that is a reserved prop (children,className,ref,render, andslotClassNameson slotted recipes), a variant that mixes boolean and named options, and slotted variant values that are not slot className maps - a variant key that shadows an
Object.prototypemember (constructor,toString,valueOf, ...): a prop bag without that prop would otherwise read the inherited member styled()given a value that is not a recipe,withRenderon a non-intrinsic base, a slotted recipe orviewPropswithoutview, or an undeclared or missinghostSlot
Strict mode adds these checks when a recipe is created, and then deep-freezes the config, including the nested maps of a config that is already frozen:
- undeclared
defaultVariantsorcompoundVariantskeys, and values those variants do not declare - invalid
classNamevalues, and slot className maps that name undeclared slots
It checks prop routing options once per styled() component, and
propAliases / forwardProps again on every resolve() call that passes
them:
- aliases or targets that are reserved props or
__proto__, aliases that are variant keys, aliases reused by two targets, and targets that are also listed inforwardProps forwardPropskeys that are not declared variantsviewPropskeys that are reserved props, variant keys, or alias keys
On every call, strict mode also rejects:
- missing required variants and undeclared option values
- unknown props on direct recipe calls, and
classNameon a direct slotted call - invalid
classNamevalues, and invalid or undeclaredslotClassNamesslots - unknown slot override props and undeclared override values
- a
renderprop on an intrinsic-base component withoutwithRender(render={undefined}is accepted) - an alias whose target prop is also passed directly
Lean mode skips the strict checks and ignores input it cannot use: undeclared
values add no class, invalid slotClassNames and unsupported render props
are ignored, and aliases that strict mode would reject are dropped.
There is deliberately no environment-based mode such as "validate in
development": the choice is made explicitly per factory, so the published code
never branches on process.env and the default recipe() bundle does not
contain the validating runtime at all.
Example:
const strict = defineConfig({ validate: 'always' });
const lean = defineConfig({ validate: 'never' });Package root and core are lean by default. Use 'always' in strict test
fixtures or shared packages, and use 'never' when you want to force the lean
path explicitly.
Memoizes resolved class names of root recipes so repeated identical inputs
skip variant resolution and the merge call entirely.
- enabled automatically when
mergeis set and the runtime is lean (the default, andvalidate: 'never'); has no effect withoutmerge cache: falsedisables it;cache: { maxSize }bounds it (default500, FIFO eviction);maxSize: Infinitykeeps every entry, and amaxSizebelow1disables the cache- slotted recipes are not cached: a slot resolves into several short class
strings, and
merge(e.g. tailwind-merge) already keeps its own cache for those, so memoizing each slot result would cost more than it saves - never engages in strict mode (
validate: 'always'), so validation always runs - assumes
mergeis a pure function of its input
const cached = defineConfig({ merge: twMerge }); // cache on by default
const larger = defineConfig({ merge: twMerge, cache: { maxSize: 2000 } });
const off = defineConfig({ merge: twMerge, cache: false });defineRecipeConfig() is a typed identity helper:
const buttonConfig = defineRecipeConfig({
base: 'inline-flex',
variants: {
tone: {
primary: 'text-blue-600',
ghost: 'text-slate-900',
},
disabled: {
true: 'opacity-50',
},
},
});It returns the original config reference unchanged and preserves defaultVariants editor completions plus exact key/value checking when you keep a config object in a variable.
variantNames() returns declared variant keys from either a config object or a
compiled recipe:
const names = variantNames(buttonConfig); // ['tone', 'disabled']variantOptions() returns the public values accepted for a single variant:
const tones = variantOptions(buttonConfig, 'tone'); // ['primary', 'ghost']
const disabled = variantOptions(buttonConfig, 'disabled'); // [true, false]Named variants return string option keys. Boolean variants return boolean values
[true, false], matching the values accepted by recipe calls.
- exported from both the package root and
react-class-variants/core - accepts values returned by
defineRecipeConfig()orrecipe() - returns an empty array at runtime for an unknown variant key
- typed own-property guard
- uses
Object.hasOwnwhen available and falls back toObject.prototype.hasOwnProperty.call(...) - exported from both the package root and
react-class-variants/core
- exported from the package root
- concatenates
className - shallow-merges
style - composes React event handlers with override-first ordering
- replaces other props with the override value
Use this when a wrapper or polymorphic helper needs the same prop-merging semantics as the render path.
- exported from the package root
- merges multiple refs into one callback ref
- avoids wrapping when there is only one non-null ref
- propagates React 19 callback-ref cleanups: when an inner ref returns a cleanup, the merged ref returns a combined cleanup that runs it and null-resets the refs that returned none
Use this in non-hook code such as cloneElement() or conditional branches.
- exported from the package root
- memoized hook form of
mergeRefs(...), including cleanup propagation
Use this inside React components when you need one ref prop to update multiple refs.
PropAliasesViewPropsDescriptorRenderFunctionPropsRenderPropStyledComponent: the typestyled()returns, a React function componentStyledComponentPropsHostRenderOverridesHostViewRootStyledOptionsSlotStyledOptionsRootStyledViewPropsSlotStyledViewPropsStyledFn
| Error | Cause | Fix |
|---|---|---|
unknown recipe prop "type" |
direct recipe calls are variant-only APIs | use resolve() when you need arbitrary props |
className cannot be passed directly to a slotted recipe call |
slot recipes route class overrides at the slot-function level | use a slot renderer or resolve() |
slotted recipes require a view component |
a slotted recipe renders only through view |
pass view to styled() |
slotted recipes without a "root" slot require hostSlot |
the recipe has no default host slot | provide hostSlot with a declared slot name |
hostSlot "x" is not declared in recipe.slots |
hostSlot references an unknown slot |
use one of the declared slot names |
invalid input.slotClassNames; slot "x" is not declared in recipe.slots |
slotClassNames targets an unknown slot |
only override declared slots |
variant key "slotClassNames" is reserved |
slotClassNames cannot also be a variant name |
rename the variant key |
prop alias target "className" conflicts with a reserved public prop |
aliasing would shadow a reserved React prop | choose a different alias |
forwardProps key "x" is not declared in variants |
forwardProps references a non-existent variant |
only forward declared variant keys |
compoundVariants key "x" is not declared in variants |
a compound selector references an unknown variant (validate: 'always') |
fix the selector key |
variant key "x" shadows an Object.prototype member |
variant names like toString break plain-object lookups |
rename the variant key |
styled() received a value without compiled recipe metadata |
the second argument is not a recipe, or duplicate package copies are installed | pass a recipe() result; deduplicate the package |