Class composition, variants, and Tailwind conflict resolution — 34× faster across 144K+ real-world calls.
Welcome to cvx — a focused toolkit for building and resolving class names without spreading class composition, variant logic, and Tailwind conflict handling across different libraries.
- Class composition → Compose strings, arrays, objects, and conditional class values with
cx - Typed variants → Define defaults, compounds, composition, and inferred variant props with
cv - Tailwind resolution → Compose classes and resolve conflicting Tailwind utilities with
cn - Behavioral compatibility → Preserve the expected semantics of
clsx, CVA, andtailwind-mergewhere their feature surfaces overlap - Performance-focused → Optimized hot paths backed by synthetic benchmarks and real repository corpus testing
cvx is designed to stay focused rather than grow into a general-purpose utility library. Each API has a clear responsibility, while the internals are optimized around correctness, compatibility, and fast repeated execution.
Install cvx as a single package.
bun add @obvia/cvxIt has zero runtime dependencies and exposes one public package entrypoint.
Create variants with cv, resolve Tailwind conflicts with cn, and compose ordinary class values with cx.
import { cn, cv, cx, type VariantProps } from "@obvia/cvx"
const button = cv({
// Applied to every resolved state
base: "inline-flex items-center rounded-md font-medium",
// Variant props are inferred directly from these keys
variants: {
intent: {
primary: "bg-blue-600 text-white",
secondary: "bg-white text-slate-900",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4",
lg: "h-12 px-6 text-lg",
},
disabled: {
true: "cursor-not-allowed opacity-50",
false: "cursor-pointer",
},
},
// Used when a variant prop is omitted
defaults: {
intent: "primary",
size: "md",
disabled: false,
},
})
// Derive the public variant contract
type ButtonVariants = VariantProps<typeof button>
// Resolve one component state
button({
intent: "secondary",
size: "lg",
})
// Resolve Tailwind conflicts
cn("px-2", "px-4")
// "px-4"
// Compose conditional class values without conflict removal
cx("button", true && "active", {
disabled: false,
})
// "button active"Each API has a distinct responsibility. They can be used independently or together depending on how much class-processing behavior a component requires.
The cv function creates a reusable class resolver from a declarative variant definition.
A component can define base classes, variant axes, defaults, compound conditions, composition, and runtime overrides while keeping its public variant contract inferred by TypeScript.
Use base for classes that should be included in every resolved state.
import { cv } from "@obvia/cvx"
const button = cv({
// Always emitted before variant and compound classes
base: "inline-flex items-center rounded-md font-medium",
})
button()
// "inline-flex items-center rounded-md font-medium"base accepts the same class-value shapes used throughout cvx, so it can also contain arrays or conditional dictionaries.
const button = cv({
// Class values are normalized when the component is prepared
base: [
"inline-flex items-center",
"rounded-md",
{
"select-none": true,
},
],
})
button()
// "inline-flex items-center rounded-md select-none"Use variants to define named variant axes and the classes emitted by each possible value.
const button = cv({
base: "inline-flex items-center rounded-md",
variants: {
// `intent` becomes a typed runtime prop
intent: {
primary: "bg-blue-600 text-white",
secondary: "bg-white text-slate-900",
danger: "bg-red-600 text-white",
},
// `size` becomes another independent variant prop
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4",
lg: "h-12 px-6 text-lg",
},
},
})
button({
intent: "primary",
size: "sm",
})
// "inline-flex items-center rounded-md bg-blue-600 text-white h-8 px-3 text-sm"Variant values are inferred directly from the authored definition.
button({
// ✓ "primary" | "secondary" | "danger"
intent: "danger",
// ✓ "sm" | "md" | "lg"
size: "lg",
})Invalid authored values are rejected by TypeScript.
button({
// TypeScript error: "ghost" is not part of the intent variant
intent: "ghost",
})Use defaults to select values when variant props are omitted.
const badge = cv({
variants: {
tone: {
neutral: "bg-slate-100 text-slate-900",
success: "bg-green-100 text-green-900",
},
size: {
sm: "px-2 py-0.5 text-xs",
md: "px-2.5 py-1 text-sm",
},
},
defaults: {
// Used whenever `tone` is not provided
tone: "neutral",
// Used whenever `size` is not provided
size: "md",
},
})
badge()
// "bg-slate-100 text-slate-900 px-2.5 py-1 text-sm"
badge({
// Only the explicitly provided axis changes
tone: "success",
})
// "bg-green-100 text-green-900 px-2.5 py-1 text-sm"At runtime, missing-like undefined, null, and empty selections fall back to the configured default.
An explicit unknown value does not silently fall back to that default. The invalid axis simply emits no variant class while other valid axes continue resolving normally.
const component = cv({
base: "base",
variants: {
tone: {
soft: "tone-soft",
hard: "tone-hard",
},
size: {
sm: "size-sm",
lg: "size-lg",
},
},
defaults: {
tone: "soft",
size: "sm",
},
})
// Dynamic or untyped data can still reach the runtime.
// The unknown `tone` value does not replace itself with "soft".
component({
tone: "unknown" as "soft",
size: "lg",
})
// "base size-lg"Variant keys authored as true and false are exposed as booleans at runtime.
const control = cv({
variants: {
disabled: {
// Runtime value: true
true: "cursor-not-allowed opacity-50",
// Runtime value: false
false: "cursor-pointer opacity-100",
},
},
defaults: {
// The runtime API uses a boolean rather than the string "false"
disabled: false,
},
})
control({
disabled: true,
})
// "cursor-not-allowed opacity-50"
control({
disabled: false,
})
// "cursor-pointer opacity-100"This keeps boolean variants ergonomic without requiring string values at call sites.
Numeric keys can be selected with numeric runtime values.
const surface = cv({
variants: {
elevation: {
// Runtime value: 0
0: "shadow-none",
// Runtime value: 1
1: "shadow-sm",
// Runtime value: 2
2: "shadow-md",
},
},
defaults: {
elevation: 0,
},
})
surface({
elevation: 2,
})
// "shadow-md"Boolean and numeric variants can be mixed with ordinary string variants in the same component.
Use compounds when a class should be emitted only when several resolved variant selections match together.
const button = cv({
variants: {
intent: {
primary: "bg-blue-600",
danger: "bg-red-600",
},
size: {
sm: "h-8 px-3",
lg: "h-11 px-5",
},
},
compounds: [
{
// This class is emitted only when both selectors match
intent: "danger",
size: "lg",
class: "font-semibold ring-2 ring-red-300",
},
],
})
button({
intent: "danger",
size: "lg",
})
// "bg-red-600 h-11 px-5 font-semibold ring-2 ring-red-300"
button({
intent: "danger",
size: "sm",
})
// "bg-red-600 h-8 px-3"Every selector in a compound rule must match before the rule is emitted.
A compound selector can accept an array when the same rule should match several values.
const button = cv({
variants: {
intent: {
primary: "bg-blue-600",
secondary: "bg-slate-100",
danger: "bg-red-600",
},
size: {
sm: "h-8",
md: "h-10",
lg: "h-12",
},
},
compounds: [
{
// Match either primary or danger
intent: ["primary", "danger"],
// But only when size is small
size: "sm",
class: "text-xs font-semibold",
},
],
})
button({
intent: "primary",
size: "sm",
})
// "bg-blue-600 h-8 text-xs font-semibold"
button({
intent: "danger",
size: "sm",
})
// "bg-red-600 h-8 text-xs font-semibold"Arrays can be used on more than one selector to describe a larger matching matrix without duplicating rules.
const item = cv({
variants: {
tone: {
neutral: "tone-neutral",
success: "tone-success",
danger: "tone-danger",
},
size: {
sm: "size-sm",
md: "size-md",
lg: "size-lg",
},
},
compounds: [
{
// Any tone in this list...
tone: ["success", "danger"],
// ...combined with any size in this list matches
size: ["sm", "md"],
class: "font-medium",
},
],
})Every resolver accepts a final class or className value.
const button = cv({
base: "inline-flex rounded-md",
variants: {
intent: {
primary: "bg-blue-600 text-white",
secondary: "bg-white text-slate-900",
},
},
})
button({
intent: "primary",
// Appended after the resolved component classes
class: "w-full justify-center",
})
// "inline-flex rounded-md bg-blue-600 text-white w-full justify-center"className provides the equivalent API for environments where that naming is preferred.
button({
intent: "secondary",
// Equivalent runtime override using `className`
className: "shadow-sm",
})class and className are mutually exclusive in the inferred TypeScript contract, preventing ambiguous double overrides.
button({
// Use one or the other
class: "w-full",
// TypeScript rejects supplying both together
className: "shadow-sm",
})Runtime overrides affect only the final emitted classes. They do not change variant selection or compound matching.
Use composes to combine existing cv components while inheriting their variants and defaults.
const tone = cv({
base: "transition-colors",
variants: {
intent: {
primary: "bg-blue-600 text-white",
danger: "bg-red-600 text-white",
},
},
defaults: {
intent: "primary",
},
})
const size = cv({
variants: {
size: {
sm: "h-8 px-3",
md: "h-10 px-4",
},
},
defaults: {
size: "sm",
},
})
const button = cv({
// Both components contribute their classes, variants, and defaults
composes: [tone, size],
// Local classes are emitted after composed component output
base: "inline-flex items-center rounded-md",
})
button()
// "transition-colors bg-blue-600 text-white h-8 px-3 inline-flex items-center rounded-md"
button({
// Both inherited variant props remain available
intent: "danger",
size: "md",
})A single component can be passed directly without wrapping it in an array.
const action = cv({
// Single-component composition is supported
composes: button,
base: "font-semibold",
})A composed component can retune defaults inherited from its parents.
const button = cv({
composes: [tone, size],
base: "inline-flex items-center rounded-md",
defaults: {
// Override the default inherited from `tone`
intent: "danger",
// Override the default inherited from `size`
size: "md",
},
})
button()
// Resolves with intent="danger" and size="md"Local defaults take precedence over inherited defaults without removing the inherited variant definitions.
Compound rules can target variants inherited through composition.
const button = cv({
composes: [tone, size],
base: "inline-flex items-center",
defaults: {
intent: "danger",
size: "md",
},
compounds: [
{
// Both selectors come from composed components
intent: "danger",
size: "md",
class: "ring-2 ring-red-300",
},
],
})
button()
// Includes the compound because the inherited variants resolve to danger + mdThis allows composition to behave as one effective variant surface instead of several disconnected resolvers.
Composition can be nested without losing inherited variant types or defaults.
const base = cv({
base: "base",
variants: {
tone: {
soft: "bg-slate-100",
strong: "bg-slate-900 text-white",
},
},
defaults: {
tone: "soft",
},
})
const panel = cv({
// Inherits `tone` from base
composes: base,
base: "rounded-lg",
})
const dialog = cv({
// `tone` is still available through the nested composition chain
composes: panel,
base: "shadow-xl",
defaults: {
// Retune the inherited default at the outermost level
tone: "strong",
},
})
dialog()
// "base bg-slate-900 text-white rounded-lg shadow-xl"
dialog({
tone: "soft",
})
// "base bg-slate-100 rounded-lg shadow-xl"Composition is resolved in authored order, followed by the local component and its final runtime override.
A cv component snapshots its authored runtime configuration when it is created.
Later mutations to the original object do not change the component's behavior.
const config = {
base: "before",
variants: {
tone: {
soft: "tone-soft",
},
},
defaults: {
tone: "soft",
},
}
const component = cv(config)
// Mutating the authored object afterwards does not rewrite the component
config.base = "after"
config.variants.tone.soft = "changed"
component()
// "before tone-soft"This keeps prepared components deterministic after creation.
Every cv component exposes its effective prepared configuration through config.
const button = cv({
variants: {
size: {
sm: "h-8",
md: "h-10",
},
},
defaults: {
size: "md",
},
})
// Effective variant metadata retained by the component
button.config.variants
// Effective defaults retained by the component
button.config.defaultsThis metadata is primarily used to preserve type information and composition behavior between cv components.
Use VariantProps to extract the public variant contract from a resolver created with cv.
import {
cv,
type VariantProps,
} from "@obvia/cvx"
const button = cv({
variants: {
intent: {
primary: "bg-blue-600",
secondary: "bg-white",
},
size: {
sm: "h-8",
md: "h-10",
},
disabled: {
true: "opacity-50",
false: "opacity-100",
},
},
defaults: {
intent: "primary",
disabled: false,
},
})
// Extract only the public variant selections
type ButtonVariants = VariantProps<typeof button>The inferred type is equivalent to:
type ButtonVariants = {
intent?: "primary" | "secondary"
size?: "sm" | "md"
disabled?: boolean
}Runtime class, className, and internal variant metadata are excluded from VariantProps.
This makes the type suitable for component props without duplicating the variant contract.
type ButtonProps = ButtonVariants & {
children: React.ReactNode
}The cn function combines ordinary class composition with Tailwind-aware conflict resolution.
Inputs are normalized first, then conflicting Tailwind utilities are resolved while unrelated and unknown classes are preserved.
When two recognized utilities belong to the same Tailwind conflict group, the later applicable utility wins.
import { cn } from "@obvia/cvx"
cn(
"px-2",
"px-4",
)
// "px-4"Multiple independent groups are resolved separately.
cn(
"px-2",
"py-1",
"text-sm",
"px-4",
"text-lg",
)
// "py-1 px-4 text-lg"cn accepts the same class-value shapes as cx.
cn(
// Plain strings
"flex items-center",
// Conditional expressions
active && "font-medium",
// Nested arrays
[
"px-2",
compact && "py-1",
],
// Conditional dictionaries
{
"opacity-50": disabled,
"cursor-pointer": !disabled,
},
// This conflicts with the earlier px-2
"px-4",
)Composition happens before Tailwind conflict resolution.
Tailwind conflicts are resolved only inside the relevant modifier scope.
cn(
// Base scope
"p-2",
// Hover scope
"hover:p-2",
"hover:p-4",
// Focus scope
"focus:p-6",
// Responsive scope
"md:p-8",
)
// "p-2 hover:p-4 focus:p-6 md:p-8"A base utility does not remove a responsive or state-specific utility simply because they share the same underlying class group.
Stacked modifier combinations follow Tailwind merge semantics.
cn(
// Both represent the same effective modifier scope
"md:hover:p-2",
"hover:md:p-4",
)
// "hover:md:p-4"This applies to responsive, state, data, and other supported modifier combinations.
Arbitrary Tailwind values participate in normal conflict resolution.
cn(
// Both belong to the width group
"w-[10px]",
"w-[calc(100%-2rem)]",
)
// "w-[calc(100%-2rem)]"Typed arbitrary values are resolved according to their Tailwind group.
cn(
"text-[length:12px]",
"text-lg",
)Arbitrary CSS properties conflict with later values for the same property.
cn(
"[color:red]",
"[color:blue]",
)
// "[color:blue]"Different arbitrary properties remain independent.
cn(
"[color:red]",
"[mask-type:luminance]",
)Arbitrary modifiers retain their own conflict scope.
cn(
"data-[state=open]:p-2",
"data-[state=open]:p-4",
"data-[state=closed]:p-6",
)
// "data-[state=open]:p-4 data-[state=closed]:p-6"Only utilities under the same modifier scope are compared as conflicts.
Important utilities follow the same conflict rules within their matching scope.
cn(
"!p-2",
"!p-8",
"p-4",
)
// "!p-8 p-4"Important and non-important utilities are not collapsed into one another when their effective conflict semantics differ.
Utilities that use slash modifiers are parsed as part of Tailwind conflict resolution.
cn(
"text-lg/6",
"text-sm/7",
)
// "text-sm/7"Logical and physical spacing relationships follow Tailwind merge behavior.
cn(
"ps-2",
"pe-2",
// The later physical x-axis utility replaces the applicable logical values
"px-4",
)The same behavior applies to supported margin and scroll-spacing relationships.
Built-in Tailwind animation utilities resolve according to their known animation group.
cn(
"animate-spin",
"animate-pulse",
)
// "animate-pulse"Unknown or custom animation names are not incorrectly collapsed just because they begin with animate-.
cn(
// Custom animation utilities are preserved when Tailwind does not
// classify them as members of the same known conflict group
"animate-fade-out",
"animate-slide-out-down",
)This distinction is important for ecosystems that build additional animation utilities on top of Tailwind.
Classes that are not recognized as conflicting Tailwind utilities are preserved.
cn(
// Application-specific classes remain untouched
"component-root",
"plugin:state",
// Known Tailwind utilities still resolve normally
"p-2",
"p-4",
)
// "component-root plugin:state p-4"cn therefore does not treat every class-like token as a Tailwind utility.
Use cn when both of these behaviors are required:
// 1. Compose conditional class values
// 2. Resolve Tailwind conflicts in the resulting class string
cn(
"button px-2",
active && "button-active",
large && "px-6",
)If conflict resolution is not wanted, use cx instead.
The cx function normalizes and combines class values without interpreting them as Tailwind utilities.
It is the lowest-level composition API exposed by cvx.
Plain strings are appended in authored order.
import { cx } from "@obvia/cvx"
cx(
"button",
"rounded-md",
"font-medium",
)
// "button rounded-md font-medium"Falsy conditional expressions are ignored.
cx(
"button",
// Included only when active is truthy
active && "button-active",
// Included only when disabled is truthy
disabled && "button-disabled",
)The boolean sentinel true itself is not emitted as a class.
cx(
"button",
true,
)
// "button"Arrays are flattened recursively while preserving their original order.
cx(
"button",
[
"rounded-md",
// Nested arrays can be arbitrarily deep
[
active && "button-active",
[
compact && "button-compact",
],
],
],
)No temporary flattened array is required at the API level.
Object keys are emitted when their corresponding values are truthy.
cx({
// Included
"font-medium": true,
// Included only when disabled is truthy
"opacity-50": disabled,
// Omitted
"pointer-events-none": false,
})Only the object's own enumerable properties are considered.
Inherited properties are ignored.
const inherited = {
inherited: true,
}
const classes = Object.create(inherited)
classes.own = true
cx(classes)
// "own"Numeric class values are supported and converted to strings.
cx(
"item",
1,
2,
)
// "item 1 2"This preserves compatibility with established class-composition behavior.
Falsy values are omitted from the result.
cx(
"button",
false,
null,
undefined,
0,
"",
)
// "button"bigint values are also ignored to preserve clsx runtime parity.
cx preserves authored class order.
cx(
"first",
["second", "third"],
{
fourth: true,
},
)
// "first second third fourth"Repeated evaluation of the same values remains deterministic.
cx never removes classes simply because Tailwind would consider them conflicting.
cx(
"px-2",
"px-4",
)
// "px-2 px-4"This is the fundamental difference between cx and cn.
// Composition only
cx("px-2", "px-4")
// "px-2 px-4"
// Composition + Tailwind conflict resolution
cn("px-2", "px-4")
// "px-4"Use cv when class output depends on a reusable typed component state.
// Reusable typed variants
const button = cv({
variants: {
size: {
sm: "h-8",
md: "h-10",
},
},
})Use cn when class values are composed dynamically and Tailwind conflicts should be resolved.
// Dynamic classes + Tailwind conflict resolution
cn(
"px-2",
large && "px-6",
)Use cx when values should only be composed and preserved as authored.
// Dynamic classes without Tailwind interpretation
cx(
"px-2",
large && "px-6",
)The three APIs are intentionally separate so class composition, variant resolution, and Tailwind conflict handling can be used independently instead of forcing every call through the same abstraction.
The cvx project is designed with performance as a first-class concern across class composition, variant resolution, and Tailwind conflict handling.
| Scenario | Baseline | Baseline | CVX | Relative |
|---|---|---|---|---|
cx flat composition |
clsx |
18.86 ns | 24.46 ns | 0.77× |
cx nested + conditional |
clsx |
170.75 ns | 126.70 ns | 1.35× |
cv hot explicit variants |
cva 1.0 beta |
264.49 ns | 39.05 ns | 6.77× |
cv · 64 compound rules |
cva 1.0 beta |
4.02 µs | 34.66 ns | 115.96× |
cv creation + first call |
cva 1.0 beta |
1.43 µs | 1.54 µs | 0.93× |
cn · 32-entry working set |
clsx + tailwind-merge |
163.94 ns | 20.50 ns | 8.00× |
cn · arbitrary values + modifiers |
clsx + tailwind-merge |
199.83 ns | 19.35 ns | 10.33× |
cv + cn end-to-end |
cva + clsx + tailwind-merge |
693.14 ns | 88.25 ns | 7.85× |
cv performs more work while preparing a component so that repeated resolutions can use a significantly
cheaper hot path. For this reason, short-lived component creation and repeated component resolution are
measured separately.
Synthetic benchmarks are complemented by a corpus benchmark that replays every captured cn() call from
58 open source repositories, covering 144,265 calls in total.
You can find the latest benchmark results and complete per-repository measurements by visiting the link below.
The cvx project welcomes contributions from the community.
Whether you want to report a bug, suggest a new feature, improve the documentation, or submit code changes, your contributions are greatly appreciated.
You can find detailed information about the contribution process by visiting the link below.
The cvx project takes security vulnerabilities seriously.
If you believe you have discovered a security vulnerability, please report it responsibly by contacting Selçuk Çukur at hello@selcukcukur.me.
Please do not disclose security vulnerabilities publicly until they have been reviewed and addressed.
You can find detailed information about the security policy by visiting the link below.
The cvx project is published as open source software under the MIT License, which is one of the most widely used open source licenses.
You can find detailed information about the license terms by visiting the link below.