DEV Community

Cover image for I rewrote cva to be stricter and faster. Then it ran on React Native too
Tim Phan
Tim Phan

Posted on Fully Autonomous

I rewrote cva to be stricter and faster. Then it ran on React Native too

class-variance-authority (cva) is one of those libraries I reach for in every project. You describe a component's variants, and it turns props into class names. I've used it for years, and two things kept bothering me:

  • The types are loose. Every variant is optional, so forgetting to pass one still compiles.
  • Every call starts from scratch. A recipe rebuilds its class names on every render, even when the props haven't changed. Wrap it in cn() and tailwind-merge runs on every render too.

So I started rewriting it, stricter and with performance in mind. Along the way, the core turned into an engine that knows nothing about class names, and that engine now powers a React Native version as well. This post walks through that path: @lynstack/class-recipe, the engine, @lynstack/native-recipe, and a few features I'm happy with.

A stricter cva

It looks just like cva:

import { cva } from "@lynstack/class-recipe";

const button = cva({
  base: "inline-flex items-center rounded-md font-medium",
  variants: {
    tone: {
      neutral: "bg-gray-100 text-gray-900",
      danger: "bg-red-600 text-white",
    },
    size: {
      sm: "h-8 px-3 text-sm",
      md: "h-10 px-4",
    },
  },
  defaultVariants: { size: "md" },
});

button({ tone: "danger" });
// => "inline-flex items-center rounded-md font-medium bg-red-600 text-white h-10 px-4"
Enter fullscreen mode Exit fullscreen mode

The difference is in what TypeScript lets through.

A variant without a default is required. tone has no default, so leaving it out is an error:

// @ts-expect-error: tone has no default, so it is required.
button({ size: "sm" });
Enter fullscreen mode Exit fullscreen mode

To make a variant optional, give it a default. To add no classes when it's left out, declare an empty option, such as none: "", and make it the default.

A compound variant can't silently never match. Its condition sits under variants, apart from its classes, and naming a variant or option that doesn't exist is a type error:

compoundVariants: [
  { variants: { tone: "danger", size: "md" }, className: "font-semibold" },
],
Enter fullscreen mode Exit fullscreen mode

Boolean variants need only one side. invalid: { true: "..." } gets a false option for free, defaults to false, and accepts both true and "true".

Slots. When a component has several elements, such as a card with a root, a header, and a body, sva gives each slot its own class name from one set of variants. cva has no slots.

Moving over from cva is mostly mechanical: base goes inside the config, and compound variants move their condition under variants. The two libraries can live side by side, so you can migrate one component at a time.

Performance in mind

A recipe does its work in two phases.

When it's created, it compiles the config once. It numbers the options of each variant, and turns each compound variant into the option numbers it matches.

When it's called, it turns the props into a single integer. Each variant takes one "digit" of that number, the way ones and tens do, with as many values as the variant has options. That integer is the cache key. The first call with a new combination builds the class name. Every call after that is one cache lookup, with no allocations.

Here are the numbers on an Apple M1 Pro with Node.js 24. Each library runs the same recipe with six combinations of props in turn, and returns the same class names:

Millions of calls per second class-recipe 1.5.0 cva 0.7.1 tailwind-variants 3.3.1
Without merging 13.2 1.4 1.0 (lite)
With tailwind-merge 3.9 0.9 1.0

"So it's just the cache?" Mostly, yes. But with the cache turned off, it still runs 7.8 million calls per second, about five times cva.

The tailwind-merge row is where the cache matters most. Instead of cn(button(props)), you create the functions once with your own join:

// src/lib/recipe.ts
import { createRecipes } from "@lynstack/class-recipe";
import { twMerge } from "tailwind-merge";

export const { cx, cva, sva } = createRecipes({ join: twMerge });
Enter fullscreen mode Exit fullscreen mode

Now a recipe calls twMerge once per combination of variants and caches the result. Only calls that pass className merge again. The join can be any function, such as shadcn's cn.

It also ships cx, a drop-in replacement for clsx that's as fast or a little faster on every input I measured.

To be fair: one recipe call is fast in every one of these libraries. On a page with a few buttons, you won't notice a difference. It shows up in tables with hundreds of rows, in components that render on every keystroke, or when twMerge runs on every render. Measure your app before you optimize it.

Splitting out the engine

When I started on a React Native version, I noticed that most of the code was the same. Which option to pick, which compound variants match, in what order: none of that cares whether a value is a string or an object. Only one thing differs: how two values combine.

So I moved that one thing out into a "kind," and everything else into an engine, @lynstack/recipe. A kind is up to three functions:

  • initial(base) starts an accumulator from the base.
  • reduce(acc, value) adds one value to it.
  • finish(acc), optional, turns it into the result.

Here's a kind for style objects:

import { createRecipeKind } from "@lynstack/recipe";

type Style = Readonly<Record<string, string | number>>;

const styleRecipe = createRecipeKind({
  initial: (base?: Style): Style => ({ ...base }),
  reduce: (style, value: Style): Style => ({ ...style, ...value }),
  finish: (style): Style => Object.freeze(style),
});

const text = styleRecipe({
  base: { color: "black" },
  variants: {
    size: { sm: { fontSize: 12 }, lg: { fontSize: 24 } },
    muted: { true: { opacity: 0.6 } },
  },
  compoundVariants: [
    { variants: { size: "lg", muted: true }, value: { fontWeight: 300 } },
  ],
  defaultVariants: { size: "sm" },
});

text({ size: "lg", muted: true });
// => { color: "black", fontSize: 24, opacity: 0.6, fontWeight: 300 }
Enter fullscreen mode Exit fullscreen mode

The engine handles variants, compound variants, defaults, slots, the cache, and the types of the props. The kind only says how to combine. The engine has no dependencies, and you can write a kind for anything that combines. class-recipe is now just a kind for class names, plus cx.

React Native

@lynstack/native-recipe is a kind for React Native style objects, with what an app needs on top:

import { createSlotStyleRecipe } from "@lynstack/native-recipe";

const button = createSlotStyleRecipe({
  slots: ["root", "label"],
  base: { root: { borderRadius: 8 }, label: { fontWeight: "600" } },
  variants: {
    tone: {
      primary: {
        root: { backgroundColor: "#2563eb" },
        label: { color: "#ffffff" },
      },
      ghost: { label: { color: "#2563eb" } },
    },
    size: { sm: { root: { height: 32 } }, md: { root: { height: 40 } } },
  },
  defaultVariants: { tone: "primary", size: "md" },
});

const styles = button({ size: "sm" });
styles.root; // => { borderRadius: 8, backgroundColor: "#2563eb", height: 32 }

button({ size: "sm" }) === styles; // => true
Enter fullscreen mode Exit fullscreen mode

The last line is the point. The same props return the same frozen object, so the style prop keeps its identity between renders, and React.memo isn't defeated by a style object created on every render.

It also has createThemedRecipes, which builds styles from design tokens for a light and a dark theme, and caches each theme's styles separately.

A few features I like

Composing recipes. Many components share classes and variants. Instead of copying a config, list the shared recipe in composes:

const iconButton = cva({
  composes: [button],
  base: "justify-center",
  variants: {
    size: { sm: "w-8", md: "w-10" },
  },
});
Enter fullscreen mode Exit fullscreen mode

iconButton accepts every variant of button, and behaves as if you had written both configs as one. The configs are merged once, when the recipe is created, so a call costs what a hand-written recipe costs. Slot recipes compose too, and so do the recipes of React Native.

Recipes that never conflict. With the default join, every class stays, so px-4 and px-2 on one element means the stylesheet picks the winner. The docs have a short set of rules that design conflicts away, built on one idea: set each CSS property of an element in one place. Follow them, and you don't need tailwind-merge at all.

Agent skills. Coding agents tend to write recipes as if classes were always merged with twMerge. Both packages ship an agent skill that teaches them the rules of the package: conflict-free classes for class-recipe, stable styles built from theme tokens for native-recipe.

npx skills add lynstack/recipe --skill class-recipe
Enter fullscreen mode Exit fullscreen mode

Listing the variants. Every recipe exposes variantKeys, variantOptions, and defaultVariants, typed and frozen. Use them to split a component's props into variants and the rest, or to render every look of a component in a story or a test.

A cache you can turn off. The cache never evicts, and grows up to one entry per combination of declared options. That's fine for UI. On a server, where variants may come from untrusted input, a recipe can opt out with cache: false.

Try it

npm install @lynstack/class-recipe   # class names
npm install @lynstack/native-recipe  # React Native
npm install @lynstack/recipe         # the engine, for your own kinds
Enter fullscreen mode Exit fullscreen mode
  • Docs: https://lynstack.github.io/recipe/
  • The docs have guides for migrating from cva and from tailwind-variants, and for using class-recipe with shadcn/ui.
  • Each package has an example you can open right away on StackBlitz or Expo Snack.

All three packages are ES modules with full types, MIT-licensed. If something doesn't work for you, or you find a case where it isn't faster, please open an issue. Those are the reports I most want to see.

Top comments (0)