Skip to content

feat(cva): add boolean variant shorthand - #384

Draft
joe-bell wants to merge 2 commits into
mainfrom
claude/tailwind-variants-shorthand-75hx0e
Draft

feat(cva): add boolean variant shorthand#384
joe-bell wants to merge 2 commits into
mainfrom
claude/tailwind-variants-shorthand-75hx0e

Conversation

@joe-bell

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

Copy link
Copy Markdown
Owner

Description

Adds a boolean variant shorthand to cva@beta (tracked in Linear as JB-251): assigning a class value directly to a variant name now declares an on/off variant.

cva({ variants: { disabled: "opacity-50" } });
// expands to
cva({ variants: { disabled: { true: "opacity-50", false: null } } });

variants: { busy: null } declares a classless boolean variant that still participates in props, compoundVariants, and getSchema.

Details:

  • Any non-plain-object value (string, number, array, null) is treated as shorthand. Plain objects always stay variant maps, since a clsx class dictionary structurally overlaps one; wrap a dictionary in an array to use it as shorthand. boolean/undefined/bigint are rejected as shorthand values (bigint because clsx silently drops bigints, so it could never render).
  • Shorthand is normalized once at cva() build time (with a no-allocation fast path when no shorthand is present), so the resolution loop, composition merging, component.config.variants, and getSchema all see the expanded { true, false } map. getSchema reports shorthand variants as { values: [true, false] }.
  • VariantProps infers boolean for shorthand entries, and defaultVariants/compoundVariants accept booleans for them.
  • Composition type fix (uncovered by hardening review, pre-existing latent bug for longhand): MergedVariants used to intersect composed components' variant maps, and TS reduces conflicting option values (null & "x", or two shorthands' differing true classes) to never, poisoning VariantProps/getSchema to string | number | symbol. It is now a right-biased fold over the composed tuple (reusing MergeVariantShapes), mirroring the runtime mergeVariants reduce order — option keys still union across components.
  • Benchmark: a feature-detected Call component (boolean shorthand) scenario (same pattern as supportsComposes) keeps baseline deltas meaningful against published versions that lack the shorthand.
  • Docs: new Boolean variants section, plus updates to the API reference, What's New, Composing Components, and TypeScript pages. The two examples whose disabled maps matched the shorthand expansion byte-for-byte (react-with-tailwindcss, astro-with-tailwindcss) now use it.

Three TypeScript inference traps were found and fixed along the way, recorded in AGENTS.md Learnings: the schema value conditional must be non-distributive ([T] extends [U]) or literal defaultVariants values widen; and neither the local-over-composed nor the composed-over-composed variants merge can be a plain intersection (unit-type intersection reduction collapses conflicting option maps to never).

Additional context

  • Verified beyond unit tests (113 in the cva suite, 282 workspace-wide): packed-artifact consumer check per the AGENTS.md procedure (pnpm pack + tsc --strict --declaration under nodenext, including a composed-conflict component) confirms no internal types leak into emitted declarations; attw + publint build gates pass; all docs // => output comments executed against the built package; bench smoke run confirms the new scenario registers.
  • Worth a reviewer's eye: the shorthand discrimination rule (plain object = variant map, everything else = shorthand) and the MergedVariants fold ordering (later composed components win per option key, matching the runtime reduce).

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

https://claude.ai/code/session_01VaQeVqbK9krHwPoFPjrP1M

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 11, 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 4d78fa2 Commit Preview URL

Branch Preview URL
Jul 16 2026, 06:15 AM
@joe-bell
joe-bell force-pushed the claude/tailwind-variants-shorthand-75hx0e branch from 6625767 to 94ba27e Compare July 11, 2026 15:14
@cursor
cursor Bot force-pushed the claude/tailwind-variants-shorthand-75hx0e branch from 94ba27e to 3ab4281 Compare July 12, 2026 10:43
@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
606,892 ops/s ±0.28% 618,999 ops/s ±0.27% -2.0%
cva (runtime)
component call with props
97,851 ops/s ±0.29% 98,678 ops/s ±0.27% -0.8%
cx (runtime)
class join
5,815,710 ops/s ±0.31% 6,063,499 ops/s ±0.08% -4.1%
cva (static)
component definition
15,442,537 ops/s ±0.08% 13,999,413 ops/s ±0.32% 🟢 +10.3%
compose (static + runtime)
define, join and call
507,856 ops/s ±0.26% 519,184 ops/s ±0.34% -2.2%

cva

Task This PR 1.0.0-beta.7 (beta) Δ
cva (runtime)
component call with defaults
616,274 ops/s ±0.22% 618,062 ops/s ±0.29% -0.3%
cva (runtime)
component call with props
98,131 ops/s ±0.22% 96,442 ops/s ±0.26% +1.8%
cx (runtime)
class join
5,095,701 ops/s ±0.14% 4,924,145 ops/s ±0.14% +3.5%
cva (static)
component definition
1,572,004 ops/s ±0.11% 1,889,520 ops/s ±0.32% 🔴 -16.8%
composes property (static + runtime)
define, join and call
246,329 ops/s ±0.36% 251,301 ops/s ±0.22% -2.0%

Commit affd871 · Node v24.18.0 · linux x64 · 2026-07-15T13:59:19.126Z

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.

@cursor
cursor Bot force-pushed the claude/tailwind-variants-shorthand-75hx0e branch from 3ab4281 to 9005444 Compare July 12, 2026 18:53
Assigning a class value directly to a variant name is now shorthand
for a boolean variant: `variants: { disabled: "opacity-50" }` expands
to `{ disabled: { true: "opacity-50", false: null } }`, and
`variants: { busy: null }` declares a classless boolean variant that
still participates in props, compoundVariants, and getSchema.

Any non-plain-object value (string, number, array, null) is treated as
shorthand; plain objects always stay variant maps, since a clsx class
dictionary structurally overlaps one (wrap it in an array to use it as
shorthand). Shorthand is normalized once at cva() build time so the
resolution loop, composition merging, component.config, and getSchema
all see the expanded map.

Type-level, the config's variants slot widens to accept shorthand and
VariantProps infers `boolean` for shorthand entries. Two inference
traps surfaced (recorded in AGENTS.md Learnings): the schema value
conditional must be non-distributive or literal defaultVariants values
widen, and local/composed variants must merge via a right-biased
mapped type (MergeVariantShapes) instead of an intersection, which
collapses conflicting option keys to never.

Closes JB-251

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VaQeVqbK9krHwPoFPjrP1M
@joe-bell
joe-bell force-pushed the claude/tailwind-variants-shorthand-75hx0e branch from 9005444 to c33e972 Compare July 15, 2026 13:57
Post-review hardening of the boolean variant shorthand:

- Rewrite MergedVariants as a right-biased fold over the composed tuple
  (reusing MergeVariantShapes), mirroring the runtime mergeVariants
  reduce. The previous UnionToIntersection collapsed conflicting option
  values across composed components (near-inevitable with shorthand,
  latent for longhand) to never, poisoning VariantProps and getSchema
  with string | number | symbol.
- Drop bigint from CVAVariantShorthandValue: clsx silently drops
  bigints, so it typechecked a variant that could never render.
- Skip allocation in normalizeVariants when no value is shorthand;
  document why mergeVariants still copies unconditionally.
- Add tests: composed shorthand/longhand conflicts, number and
  empty-string shorthand, runtime tolerance pins for type-rejected
  values, explicit-undefined default fallback, compound array
  selectors, composed getSchema with defaults, bigint rejection.
- Bench: feature-detected "Call component (boolean shorthand)"
  scenario (supportsComposes precedent) so baseline deltas stay
  meaningful.
- Docs: composition and TypeScript pages cover shorthand; the two
  examples whose disabled maps matched the shorthand expansion
  byte-for-byte now use it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VaQeVqbK9krHwPoFPjrP1M
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants