Module 5: Components With Variants

Module introduction: components with variants

From "loose utilities" to "components that package them with variants"

In the four previous modules you built the system's two lower layers. Module 2 gave you tokens (color.primary, space-4, text-lg); module 3, the utilities that consume them (bg-primary, p-4, text-lg); and module 4, the scales their values come from and the contrast that guarantees they're readable. With all that, today you know how to dress an element: you write class="inline-flex items-center rounded-md bg-primary text-white px-4 py-2" on a button and it's styled, with classes pointing to your tokens and coming from your scale.

But notice what you don't have yet. That string of classes lives loose in the markup, and Mercado's button doesn't appear once: it appears in product-card, on the product page, in the mini-cart, dozens of times across the whole storefront. Right now, every occurrence hardcodes the same string. And when someone asks for "the secondary checkout button" or "a small button for the filter", you have no mechanism: you copy the string, change a few classes by hand, and pray none slip through. Utilities solved how to style one element; they didn't solve how to have one button —a single piece, with a single brand look— used a hundred times and allowing controlled variations.

That's the system's layer 3, and it's what this module is about: components. A component packages a set of utilities under a name (Button, ProductCard) so the class string lives in one single place and the whole storefront consumes it. And what makes a system component —not a simple named copy-paste— are variants: configuration axes (variant: primary/secondary/ghost; size: sm/md/lg) the component resolves through props, not by copying code. One single Button, many appearances, all coming from the same pattern. You're going to learn the pattern the industry uses for this —cva (class-variance-authority)—: a config with base, variants, defaultVariants, and compoundVariants that, given some props, resolves the final class list.

Connection with the module. This is module 5's map-lesson. It doesn't go deep into any single piece: it installs the thesis (a component packages utilities under a name, and variants configure it via props instead of copying code), gives the map of the eight lessons, and runs a first teaser of the variants() pattern —the teaching mini-version of cva you'll use all module. Lesson 2 shows why packaging utilities into a component matters (the single source). Lesson 3 presents the variant pattern and why it beats boolean props. Lesson 4 builds base + variants + defaultVariants. Lesson 5 adds compound variants. Lesson 6 connects to real cva. Lesson 7 opens up composition (slots, asChild). And lesson 8 has you build Mercado's Button with variants and apply it to product-card.

This module assumes react-fundamentals: you know a component receives props and renders JSX. Here React does not get re-taught —not useState, not events, not how a component mounts— it gets styled and varied. Everything executable is pure logic in Node (class resolution), because the browser and JSX don't run inside an agent: the component's JSX and its cva(...) get shown in blocks (it's what you'd write), and class resolution gets executed so you see, measured, what classes each combination of props produces.

An analogy: a sewing pattern with sizes and colors

Think about how a clothing brand produces a t-shirt. There are two ways to handle variety —sizes S/M/L/XL, colors black/white/blue— and only one scales.

The naive way is sewing a different garment from scratch for each case: one pattern for the small black t-shirt, another separate pattern for the large black one, another for the small blue one… With 4 sizes and 3 colors that's 12 independent patterns. If tomorrow you decide to change the collar, you have to redo all 12 patterns by hand, and it only takes one slipping through for the large black one to have a different collar than the rest. It's expensive, it's slow, and it guarantees inconsistency.

The system way is a single sewing pattern with parameters. There's one base t-shirt mold —the seams, the collar, the sleeves— and on top of it two axes that adjust it: a size axis (the mold scales) and a color axis (the same fabric in a different dye). You don't sew 12 different garments: you have one pattern and ask it for "size L, color blue", and out comes that combination. Changing the collar means touching the base mold once, and all 12 combinations inherit it. There's never a garment with a different collar "by accident", because only one mold exists.

A component with variants is exactly that sewing pattern. Button has a base mold (the classes every button shares: inline-flex, rounded-md, font-medium) and axes that adjust it: variant (the "color" —primary, secondary, ghost) and size (the "size" —sm, md, lg). You don't define nine different buttons: you define one Button with two axes, and ask for variant="primary" size="lg". Two more pieces of the analogy close out the module. The default size —the one the brand ships if you don't ask for another— is defaultVariants: if you don't pass size, Button assumes md. And that detail of "the red + XL combination gets an extra embroidery" that neither size nor color alone asks for —a rule living at the intersection of two axes— is a compound variant: at Mercado, the button that's primary and large (the main CTA) gets an extra shadow that neither "primary" nor "lg" alone justifies.

Hold on to the image: a component with variants is a sewing pattern, not twelve loose molds. Base = the shared mold; variants = the axes (size, color); defaultVariants = the default size; compound variant = the extra detail for a specific combination. The whole module develops those four pieces.

Worked example: one pattern, many combinations

The module's heart is a function: given a component defined as a config (base + variants + defaultVariants) and some props, it resolves the final class list. This teaser shows it with the bare minimum. We define Mercado's Button as a config —a base mold and two axes— and ask it for three different combinations. Notice there are not three buttons: there's one config and three calls.

// L1 intro - teaser: from loose utilities to a component that packages them with variants.
// The component is no longer a fixed string of classes: it's a function from props -> classes.

// variants() is a teaching mini-version of cva (the real library arrives in L6).
function variants(config, props = {}) {
  const classes = config.base ? [config.base] : [];
  for (const axis of Object.keys(config.variants)) {
    const value = props[axis] ?? config.defaultVariants?.[axis];
    const cls = config.variants[axis]?.[value];
    if (cls) classes.push(cls);
  }
  return classes.join(' ');
}

// Mercado's Button: one pattern, many combinations.
const button = {
  base: 'inline-flex items-center justify-center rounded-md font-medium',
  variants: {
    variant: { primary: 'bg-primary text-white', ghost: 'bg-transparent text-primary' },
    size:    { md: 'text-base px-4 py-2', lg: 'text-lg px-6 py-3' },
  },
  defaultVariants: { variant: 'primary', size: 'md' },
};

console.log('=== one pattern, many combinations ===\n');
console.log('primary lg  ->', variants(button, { variant: 'primary', size: 'lg' }));
console.log('(no props)  ->', variants(button, {}));
console.log('ghost       ->', variants(button, { variant: 'ghost' }));

What to expect. Running the file with Node, the output is exactly this:

=== one pattern, many combinations ===

primary lg  -> inline-flex items-center justify-center rounded-md font-medium bg-primary text-white text-lg px-6 py-3
(no props)  -> inline-flex items-center justify-center rounded-md font-medium bg-primary text-white text-base px-4 py-2
ghost       -> inline-flex items-center justify-center rounded-md font-medium bg-transparent text-primary text-base px-4 py-2

Read the three lines as the module's summary. All three start the same —inline-flex items-center justify-center rounded-md font-medium— that's the base mold, the classes every Mercado button shares. What changes afterward comes from the props. The first asked for variant="primary" size="lg" and received, on top of the base, primary's classes (bg-primary text-white) and lg's (text-lg px-6 py-3). The second asked for nothing ({}) and still received a complete button: variant="primary" and size="md" came from defaultVariants —the default size. The third asked only for ghost and size fell back to its md default. One config, three combinations, three different class strings, and zero copy-paste: you didn't write three buttons, you described one with axes.

Notice what this small engine saves you. Without it, "the filter's ghost button" means copying the primary button's string and changing bg-primary text-white to bg-transparent text-primary by hand, crossing your fingers. With it, it's variant="ghost" —and the pattern supplies the base, the size, and the default. That's the difference between sewing twelve garments and having a pattern with two axes.

A question to carry through the rest of the module: if variant="primary" size="lg" is Mercado's main CTA and the design calls for that combination —and only that one— to carry an extra shadow, where do you put that class? It can't go in primary (the small primary doesn't carry it) nor in lg (the large ghost doesn't either). It lives at the intersection of the two axes. (Spoiler: it's a compound variant, and you build it in lesson 5.)

The module map

Save this route; it's how each lesson builds a part of the component layer:

Idea                                        Lesson    Key concept
──────────────────────────────────────────  ────────  ───────────────────────────────────────
From utilities to components                L2        packaging the class string into ONE
                                                      source; avoiding copy-paste drift
The variant pattern                         L3        axes (variant, size) via props; why it
                                                      beats boolean props that explode
base + variants + defaults                  L4        base mold + axes with options +
                                                      defaultVariants; resolving classes from props
Compound variants                           L5        one extra class for a COMBINATION
                                                      (primary + lg -> shadow-lg)
cva in practice                             L6        class-variance-authority: the real API;
                                                      variants() is its teaching mini-version
Composition over props                      L7        slots and asChild; composing instead of a
                                                      thousand props; the UI carries no business logic
──────────────────────────────────────────  ────────  ───────────────────────────────────────
Project: Mercado's Button                   L8        Button with variant x size + one compound,
                                                      applied to product-card's CTA

The boundary: what does NOT enter this module

Knowing the boundary saves you from mixing up what other lessons and guides cover:

  • React's mechanics —what a component is, how it's defined, useState, events, the flow of props— is the react-fundamentals prerequisite. Here we assume a component receives props and renders JSX; what we do is style and vary it. If props, children, or "component" sound new to you, that's the missing module, not this one.
  • Responsive and dark mode —the md:/lg: prefixes, the dark: variant, how a component responds to size and theme without duplicating— is module 6. Here variants are about appearance via props (variant, size); breakpoint and theme ones happen there.
  • Primitives and libraries —the shadcn/Radix model, unstyled but accessible components you style, asChild in depth, focus and keyboard— is module 7. Here we open up composition as an idea (lesson 7) and name asChild; the accessible-primitives ecosystem is the next module.
  • The component's business logic —what happens when you click "Add to cart", the cart's state, server calls— isn't this guide's job: it's react-fundamentals (state) and frontend-state-and-data (data). This module's Button looks a certain way based on its props; what it does when pressed isn't this UI layer's business.

Common mistakes

Believing a component is "copying the class string and giving it a name". What happens: a Button gets defined that returns a fixed class string, and for the secondary button a SecondaryButton gets made by copying and changing two classes. Why it happens: "component" sounds like "reusable chunk", and copy-with-a-name feels like reuse. How to spot it: you have Button, SecondaryButton, GhostButton, SmallButton… one component per appearance. How to fix it: a system component has one Button with axes (variant, size) resolved via props; appearances are values on an axis, not separate components. Duplicating the component per variant is the mistake this whole module exists to eliminate —lesson 3 takes it apart at the root.

Putting variation into boolean props (isPrimary, isLarge, isGhost). What happens: to vary the button, boolean flags get added, one per look. Why it happens: it's the first thing anyone thinks of —"if it's primary, put these classes". How to spot it: your Button accepts isPrimary and isGhost at once, two flags that contradict each other with nothing stopping it. How to fix it: use axes with mutually exclusive options (variant="primary" | "secondary" | "ghost"), where by construction only one value can hold. Lesson 3 measures how many contradictory combinations booleans generate (spoiler: most) versus zero from axes.

Expecting to re-learn React here. What happens: someone opens the module expecting to understand props, state, or events. Why it happens: components feel "React-y", so it seems like that's taught here. How to spot it: you get stuck on what a prop is, not how to style with it. How to fix it: this module assumes react-fundamentals; here props are the variant engine's input, and what we build is class resolution. If props' mechanics are missing for you, review them in their guide and come back —here we take them for granted on purpose, to focus on style.

Exercises

Exercise 1 — Component or copy-paste. For each situation, say whether it describes a component with variants (a pattern with axes) or a named copy-paste (duplicated molds):

  • (a) A Button that receives variant and size and resolves its classes from them.
  • (b) Three files: PrimaryButton, SecondaryButton, GhostButton, each with its own fixed class string.
  • (c) A ProductCard that receives product and always looks the same.
  • (d) A Badge with a tone prop (success / warning / error) that chooses the color.
See solution
  • (a) Component with variants. A pattern with two axes (variant, size) resolved via props; one source, many appearances.
  • (b) Named copy-paste. Three duplicated molds, one per appearance. A change to the shared look has to happen three times, and forgetting just one desyncs them. It's exactly what the module replaces.
  • (c) Component, no variants (yet). It receives data (product) but its appearance doesn't vary. It's a valid component; it just doesn't have style axes yet. If tomorrow you need a "compact" version for the mini-cart, that's where a variant appears.
  • (d) Component with variants. tone is an axis with three mutually exclusive options; the same pattern as Button's variant. One source, three controlled appearances.

The guiding question: does the variation come from a prop on a single pattern, or from duplicating the component? The former is a system; the latter is the shelf that grows.

Exercise 2 — Predict the resolution. The teaser defined Button with defaultVariants: { variant: 'primary', size: 'md' }. Without running anything, say what classes —after the base— variants(button, { size: 'lg' }) would receive (you pass only size, not variant).

See solution

It would receive, on top of the base: bg-primary text-white (from variant) and text-lg px-6 py-3 (from size). The key is variant: you didn't pass it, so the engine falls back to the primary default and takes its classes. You did pass size (lg), so it ignores the md default and uses lg. The complete string would be inline-flex items-center justify-center rounded-md font-medium bg-primary text-white text-lg px-6 py-3. The lesson: each axis gets resolved separately —what you pass wins, what you omit falls back to its default— it's not "all or nothing".

Exercise 3 — Place the piece in its layer. Module 1 gave you the four layers (tokens → utilities → components → patterns). For each statement about this module 5, say whether it's true or false and why:

  • (a) "This module builds the components layer, on top of module 3's utilities."
  • (b) "Variants replace utilities: Tailwind classes no longer get used."
  • (c) "Here you learn how a component manages its state with useState."
See solution
  • (a) True. It's exactly the module's spot: layer 3. A component packages utilities (layer 2, module 3) that consume tokens (layer 1, module 2) with values from scales (module 4). It doesn't replace anything; it rests on everything before it.
  • (b) False. Variants organize utilities, they don't replace them. Every variant option (primary → bg-primary text-white) is a set of Tailwind utilities; the variant pattern only decides which to apply based on props. You keep writing utilities —now inside the component's config.
  • (c) False. State (useState, events) is react-fundamentals, the prerequisite. Here the component gets styled and varied via props; what it does on interaction isn't this module's UI layer's business. The boundary marks it explicitly.

Summary and next step

In this lesson you installed module 5's thesis: a component packages utilities under a name to have a single source of truth, and variants configure it via props —not by copying code. With the sewing pattern, you saw the distinction that structures the module: a system doesn't sew twelve different garments, it has one base mold and axes (size, color) that adjust it, with a default size and some extra detail at specific intersections. And you verified it by running code: a single config for Mercado's Button resolved three combinations of props (primary lg, defaults, ghost) into three different class strings, all sharing the same base mold and with not a single copy-paste.

Before moving on you should be able to: explain why a component with variants beats duplicating the component per appearance; name the pattern's four pieces (base, variants, defaultVariants, compound); and place this module as layer 3 (components) on top of module 3's utilities.

Lesson 2 goes down to the first concrete step: from loose utilities to a component. Before variants, you need to see why packaging the class string into a component is worth it —what problem having a single source of truth solves— by measuring the "drift" that appears when the same string gets copied across half the storefront. It's the foundation on which variants make sense.

Resources

  • cva (class-variance-authority), official documentation — cva.style/docs. The pattern this module teaches, in its real form: base, variants, defaultVariants, compoundVariants. Our variants() is its teaching mini-version. In English.
  • shadcn/ui, "Components" — ui.shadcn.com/docs/components/button. A production Button built with cva on top of tokens; the real mirror of what you build in the project. In English.
  • React, "Passing Props to a Component" — react.dev/learn/passing-props-to-a-component. The react-fundamentals prerequisite: how a component receives props —the variant engine's input. In English.
  • Tailwind CSS, "Styling with utility classes" — tailwindcss.com/docs/styling-with-utility-classes. How to reuse styles by packaging them into components; this module's starting point. In English.