Every component library starts the same way. You add a Button. It needs a primary style and a danger style, so you reach for a variant prop. Simple enough.
Then someone needs a ghost button that also signals danger. You add variant="ghost-danger". Then outline-success. Then link-warning. Then secondary-danger. Each new combination feels reasonable in isolation. Six months later you have a prop that accepts seventeen strings, half of which your consumers will never discover because nothing in the type system points them there.
This is not a tooling problem. It is a modeling problem. The variant prop is doing two unrelated jobs, and conflating them is what causes the explosion.
Two orthogonal concerns
When you look at what variant is actually encoding in most button APIs, it is carrying two distinct signals:
Visual weight: how much attention the component demands. Primary buttons are loud. Ghost buttons are quiet. Outline sits between them. This is a presentation decision.
Semantic meaning: what the action communicates. Danger means destructive. Success means confirmation. Warning means proceed with caution. This is a communication decision.
These are orthogonal axes. A ghost button can be dangerous. An outline button can confirm success. There is no inherent relationship between how loud a button is and what it means. Cramming both signals into a single prop forces a false coupling that grows more expensive with every variant you add.
The fix is to give each concern its own prop.
The split
In nuka-ui, my open-source React component library built on Tailwind v4, the API separates these into two independent props:
A note on the project itself. I built nuka-ui because I kept repeating the same patterns across every project I started, personal, hobby, showcase, whatever. Rebuilding accessible, production-ready components from scratch every time is a real burden, and I got tired of it. The library exists to solve that repetition once, with accessibility and solid UX baked in from the start rather than bolted on later. It is not on npm yet. Navigation and Composites are the remaining pieces before the first publish.
-
variantcontrols visual weight:primary,secondary,outline,ghost,link -
intentcontrols semantic meaning:default,danger,success,warning
Every combination is valid. The consumer API looks like this:
<Button variant="primary" intent="default" />
<Button variant="ghost" intent="danger" />
<Button variant="outline" intent="success" />
<Button variant="secondary" intent="warning" />
Compare that to the flat approach:
<Button variant="ghost-danger" />
<Button variant="outline-success" />
<Button variant="secondary-warning" />
The flat approach is not unreadable. The problem is discoverability and scale. A consumer looking at variant's type has no way to know which combinations exist, which are intentional, and which ones you simply never got around to building. The two-prop model makes the full space explicit and uniform. Every variant works with every intent. There are no gaps.
How CVA implements the intersection
Separating the props does not make the styling simpler. It makes it honest. You still need to define what every combination looks like. That work is done through CVA's compoundVariants:
const buttonVariants = cva(baseClasses, {
variants: {
variant: {
primary: [],
secondary: [],
outline: [],
ghost: [],
link: [],
},
intent: {
default: "",
danger: "",
success: "",
warning: "",
},
},
compoundVariants: [
{
variant: "primary",
intent: "default",
className: [
"bg-[var(--nuka-accent-bg)]",
"text-[var(--nuka-text-inverse)]",
"hover:bg-[var(--nuka-accent-bg-hover)]",
],
},
{
variant: "primary",
intent: "danger",
className: [
"bg-[var(--nuka-danger-base)]",
"text-[var(--nuka-text-inverse)]",
"hover:brightness-90",
],
},
{
variant: "ghost",
intent: "danger",
className: [
"text-[var(--nuka-danger-text)]",
"hover:bg-[var(--nuka-danger-bg)]",
],
},
// one entry per variant x intent combination
],
defaultVariants: {
variant: "primary",
intent: "default",
size: "md",
},
})
5 variants multiplied by 4 intents gives you 20 compound variant entries for Button alone. You write all 20 explicitly. That is the cost of a complete, deliberate API, and it is a one-time authoring cost. Once they are written, the intersection is fully covered. Adding a consumer use case requires no library changes, just passing the two props you already have.
TypeScript enforces the contract at compile time:
interface ButtonProps extends ButtonVariantProps {
variant?: "primary" | "secondary" | "outline" | "ghost" | "link"
intent?: "default" | "danger" | "success" | "warning"
}
Invalid prop values do not survive a build. The type system reflects the actual API surface, not a historical accident of how variants accumulated.
What you gain at scale
The pattern compounds across a library. In nuka-ui it applies to Button, Alert, Badge, Tag, Code, Input, and Checkbox. Each component defines its own variant and intent axes and handles the intersections through compound variants. The mental model is consistent everywhere. A consumer who understands how Button works understands how Alert works.
Adding a new intent, say info, requires adding N compound variant entries per component, where N is the number of that component's variants. It is more work than adding a single flat variant string, but the scope is bounded and predictable. You know exactly what needs to be done, and you cannot accidentally miss a combination because the grid is explicit.
Tradeoffs, and when not to apply this
The verbosity is real. Twenty compound variant entries per component is a lot of lines. If your library is small and your variant space is stable, the flat approach is less overhead and probably fine. This pattern earns its cost at scale, when the alternative is a variant prop with an ever-growing string union that no one can hold in their head.
Consumers also have to understand two props instead of one. For most senior engineers this is a non-issue. The separation is intuitive once named. For a component library targeting less experienced consumers, the additional concept may need more documentation investment.
The pattern also does not apply universally. Banner in nuka-ui uses intent alone. There is no variant prop because Banner has one visual weight. Applying the full grid to a component with a single presentation mode would be mechanical pattern application, not design. The question to ask is whether the component genuinely has independent visual weight and semantic axes. If it does not, use the simpler model.
Two alternatives worth naming explicitly, because I considered both before landing here:
Flat variants are simpler to implement initially. The explosion problem only becomes painful as the library grows, which is exactly when you have the least appetite to refactor the API.
CSS data attributes (data-intent="danger") avoid prop surface area but lose TypeScript type safety and make the API implicit. The props approach is more explicit and more tool-friendly.
Where to see it in practice
The full implementation is in the nuka-ui repository at https://github.com/ku5ic/nuka-ui. The live Storybook at https://ku5ic.github.io/nuka-ui shows every variant and intent combination across all components. If you want to follow along as the library moves toward its first npm publish, starring the repo is the easiest way to keep up with progress.
Most component API problems are modeling problems in disguise. This one just happens to be easy to see once you know where to look.
Top comments (0)