Skip to content

feat(cva): customizable class name concatenator via cva/core - #387

Draft
joe-bell wants to merge 5 commits into
mainfrom
claude/cva-customizable-concatenator-3khasl
Draft

feat(cva): customizable class name concatenator via cva/core#387
joe-bell wants to merge 5 commits into
mainfrom
claude/cva-customizable-concatenator-3khasl

Conversation

@joe-bell

@joe-bell joe-bell commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Description

cva's underlying class name concatenator is now swappable, without baking any Tailwind-specific dependency into the library.

The package splits into two entry points:

  • cva/core — a dependency-free engine. defineConfig requires a cx concatenator and never interprets class values: it assembles the authored pieces (composed component outputs, base, matched variant and compound variant values, class/className) and passes them to the concatenator verbatim, one argument each, with its own internal arrays spread flat. The concatenator owns the grammar entirely.
  • cva — the batteries-included preset: cva/cx/compose wired up with clsx, exactly like class-variance-authority (so cx is an alias of clsx again). It also re-exports a deprecated defineConfig (with cx optional, defaulting to clsx) that points migrators at cva/core.

The authoring surface infers from the concatenator automatically, no declarations or registry needed:

  • defineConfig({ cx: twMerge }) typechecks bare and narrows base/variants/class to tailwind-merge's own ClassNameValue (object syntax becomes a compile error, per config).
  • clsx, clsx/lite, and cnfast's cn all work out of the box and keep the full ClassValue grammar.
  • An unannotated/inline concatenator (e.g. (...inputs) => twMerge(clsx(inputs))) falls back to the full ClassValue grammar, so object/array authoring stays available.

A new React example (examples/beta/react-with-tailwind-merge) demonstrates the shadcn/ui cn pattern (clsx + tailwind-merge) wired in via cva/core, with the merge visible in the rendered output.

Beta-only. class-variance-authority (stable) is untouched, and there's no version bump on this branch (releases are owner-cut).

Additional context

Areas worth a close look:

  • Generic inferencepackages/cva/src/core.ts: CXInput<TCX> derives the accepted class value type from the concatenator's parameters (Parameters<TCX>[number], with an any-detection fallback so inline functions keep the full grammar, and overload resolution taking the last signature so cnfast's variadic form infers correctly). Every new type parameter is trailing and defaulted to ClassValue, so the exported portability pins (CVAComponentShape, CVAVariantShape) and all pre-existing tests are unchanged. Declaration-emit portability is verified against a packed artifact.
  • Real-package test matrixpackages/cva/src/concatenators.test.ts exercises clsx, clsx/lite, tailwind-merge, and cnfast through cva/cx with their actual published packages, covering both inferred typing and runtime behavior (conflict resolution, grammar support, clsx/lite's documented string-only dropping).
  • Packaging — rides the tsdown build via a second cva/core entry: exports/publishConfig.exports are build-generated, and ./core's node10 fallback is a hand-maintained publishConfig.typesVersions block (mirroring the stable package's ./types). All build gates (attw strict, publint, unused) pass for both entrypoints.
  • Bundle sizesize-limit budgets are locked to measured: dist/index.cjs 1.26 kB (vs main's 1.18 kB single-entry baseline; the +80 B is the module-split glue plus the deprecated defineConfig shim), dist/core.cjs 985 B.

What is the purpose of this pull request?

  • Bug fix
  • New Feature
  • Documentation update
  • Other

Before submitting the PR, please make sure you do the following

  • Read the Contributing Guidelines.
  • Follow the Style Guide.
  • Check that there isn't already a PR that solves the problem the same way to avoid creating a duplicate.
  • Provide a description in this PR that addresses what the PR is solving, or reference the issue that it solves (e.g. fixes #123).

🤖 Generated with Claude Code

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 12, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
cva 91c6c31 Commit Preview URL

Branch Preview URL
Jul 15 2026, 02:07 PM

@socket-security

socket-security Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedcnfast@​0.0.8811009892100
Addedtailwind-merge@​3.6.01001008696100

View full report

@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Benchmarks

Comparing this PR's local benchmark run against the latest published npm versions.

Aim for higher ops/s. Treat deltas within ±5% as noise.

class-variance-authority

Note

class-variance-authority is in maintenance mode, and its latest npm baseline predates the tsdown build migration. Treat the deltas here as indicative.

Task This PR 0.7.1 Δ
cva (runtime)
component call with defaults
618,885 ops/s ±0.12% 617,508 ops/s ±0.24% +0.2%
cva (runtime)
component call with props
99,595 ops/s ±0.24% 99,427 ops/s ±0.25% +0.2%
cx (runtime)
class join
5,954,420 ops/s ±0.28% 5,794,653 ops/s ±0.13% +2.8%
cva (static)
component definition
15,347,502 ops/s ±0.08% 13,825,216 ops/s ±0.38% 🟢 +11.0%
compose (static + runtime)
define, join and call
521,811 ops/s ±0.23% 514,759 ops/s ±0.28% +1.4%

cva

Task This PR 1.0.0-beta.7 (beta) Δ
cva (runtime)
component call with defaults
614,270 ops/s ±0.19% 623,373 ops/s ±0.21% -1.5%
cva (runtime)
component call with props
95,580 ops/s ±0.20% 97,678 ops/s ±0.22% -2.1%
cx (runtime)
class join
6,172,012 ops/s ±0.25% 4,442,638 ops/s ±0.07% 🟢 +38.9%
cva (static)
component definition
1,852,413 ops/s ±0.20% 1,776,100 ops/s ±0.24% +4.3%
composes property (static + runtime)
define, join and call
250,678 ops/s ±0.22% 252,491 ops/s ±0.30% -0.7%

Commit 0af63ec · Node v24.18.0 · linux x64 · 2026-07-15T14:06:13.870Z

These benchmarks ran in CI on this PR's code. The comment checks that the report looks valid before posting it, but it cannot guarantee the numbers are correct. If you see a large change, re-run the benchmarks locally before treating it as a real improvement or regression.

claude added 5 commits July 15, 2026 14:00
Restructure around a new `cva/core` entry point whose `defineConfig`
requires a `cx` concatenator and never interprets class values: core
assembles the authored pieces (composed outputs, base, matched variant
and compound values, class/className) and passes them to the
concatenator verbatim, one argument each, with core-created arrays
spread flat. The concatenator owns the grammar entirely, so twMerge,
clsx/lite, cnfast's cn, and clsx all work within their own input
contracts — including clsx/lite for string-authored components.

The main `cva` package becomes the batteries preset: cva/cx/compose
wired up with real clsx for class-variance-authority parity ("cx is an
alias of clsx" again), plus a deprecated defineConfig re-export (cx
optional, defaulting to clsx) pointing migrators at cva/core.

Typing is handled by a new augmentable CVARegistry interface: the cx
option and the whole authoring surface default to ClassValue, and one
`declare module "cva/core"` line narrows both to a concatenator's own
input type (e.g. tailwind-merge's ClassNameValue), making bare
`cx: twMerge` typecheck while turning object-syntax authoring into a
compile error project-wide. Registry narrowing is verified in an
isolated compile (type-tests/ + check:registry) since module
augmentation is program-wide and would poison the main suite.

Adds a real-package concatenator matrix (concatenators.test.ts) with
tailwind-merge and cnfast as devDependencies alongside clsx/clsx/lite,
pinning type assignability and runtime behavior (conflict resolution,
grammar support, lite's string-only dropping) for each.

Rebuilt on top of the tsdown migration (0c6ca91), which carries the
multi-entry packaging natively: src/core.ts is a second tsdown entry,
the exports/publishConfig.exports maps are build-generated, rolldown
emits the cross-entry import with correct per-format extensions, and
./core's node10 fallback is a hand-maintained publishConfig
typesVersions block following the stable package's ./types pattern.
All build gates (attw strict, publint, unused) pass for both entries;
dist/index.cjs 1.25kB and dist/core.cjs 986B, within budget.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5Z2xhaZWz9bk24RjRu3GM
Replace the CVARegistry module-augmentation mechanism with automatic
inference: defineConfig is now generic over the passed concatenator,
and CXInput derives the accepted class value type from its parameters,
so the whole authoring surface (base, variant values, class/className)
adopts the concatenator's own input grammar with zero declarations.
`defineConfig({ cx: twMerge })` typechecks bare and turns object-syntax
authoring into a compile error, per config rather than per project;
clsx, clsx/lite, and cnfast's cn keep the full ClassValue grammar.

Inference edges, pinned by tests: unannotated inline concatenators
(contextually typed any[]) and wider-than-ClassValue signatures fall
back to ClassValue; Parameters picks an overloaded concatenator's last
signature (cnfast's variadic form); every new type parameter is
trailing and defaulted to ClassValue, so the exported portability pins
(CVAComponentShape, CVAVariantShape) and all 94 legacy tests are
untouched. The change is types-only: the built output is
byte-comparable and all build gates stay green.

The registry's isolated type-test compile (type-tests/,
tsconfig.type-tests.json, check:registry) is deleted — with no
program-wide augmentation, inference assertions live in the main suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5Z2xhaZWz9bk24RjRu3GM
Lock the bundle budgets to the branch's measured output plus a small
margin so CI fails on any future byte drift: the preset entry
(dist/index.cjs, bundling core and clsx — the `import from "cva"`
cost) measures 1.26 kB and is capped at 1.3 kB, and the standalone
core entry (dist/core.cjs, dependency-free) measures 985 B and is
capped at 1 kB.

For the record against main's single-entry baseline (measured 1.18 kB
in a clean worktree): the preset entry carries +80 B of module-split
glue and the deprecated defineConfig shim — the full cost of the
cva/core split for preset consumers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5Z2xhaZWz9bk24RjRu3GM
Add examples/beta/react-with-tailwind-merge, a React + Tailwind CSS
example wiring shadcn/ui's `cn` pattern into cva: clsx resolves the
authoring grammar and tailwind-merge dedupes conflicting utilities,
composed as the `cx` concatenator via cva/core. The button's base
carries bg/text defaults that each intent variant overrides, so the
merge is visible in the rendered output (and a consumer className wins
for the same reason).

Surface it in the beta React with Tailwind CSS docs page under a new
"Merging Tailwind classes" section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5Z2xhaZWz9bk24RjRu3GM
Rename the example's exported concatenator from `cx` to `cn` so it
matches shadcn/ui's `cn` helper verbatim (same clsx + tailwind-merge
composition), and link the shadcn manual-installation reference from
both the config comment and the docs page.

https://ui.shadcn.com/docs/installation/manual#add-a-cn-helper

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5Z2xhaZWz9bk24RjRu3GM
@joe-bell
joe-bell force-pushed the claude/cva-customizable-concatenator-3khasl branch from e84d158 to 91c6c31 Compare July 15, 2026 14:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants