Skip to content
Chapter 22Lesson 3

Polymorphism with Slot and CVA

Build a shadcn-style polymorphic component using class-variance-authority for its variant styles and Radix Slot with asChild to let the caller swap the rendered element.

A <Button> is one of the few components that genuinely leads two lives. Half the time it’s an action: submit the form, delete the row, open the menu. A real <button> is the right element for that. The other half it’s navigation, like “Open dashboard,” “View invoice,” or “Back to billing,” and the right element there is an <a>. The browser bakes a whole contract into anchors: Cmd-click opens a new tab, middle-click does too, the link shows up in the right-click menu, and a screen reader announces “link, Open dashboard.” It’s the same blue rounded box on screen, but two completely different things underneath. The table of classes that paints that box has nothing to do with which element wins, so you shouldn’t have to rebuild that table once for the button and again for the link.

In The typed props contract you gave <Button> a small, named contract: variant and size as string-literal unions. You left one piece deliberately unbuilt, a buttonClasses({ variant, size }) placeholder described as “a function that returns the class string for a combination.” In Children and compound components you learned the instinct that closes this whole problem: compose, don’t configure. This lesson builds the two tools that finish the job. The first is the variant table that is buttonClasses, declared once and typed for free. The second is a slot that lets the caller swap the element while your component keeps owning the classes and behavior. By the end you’ll have the exact <Button> that sits, nearly character-for-character, behind every shadcn primitive you’ll meet later in the course. There’s no new code yet, so start with the half you already understand.

From a hand-rolled class function to a variant table

Section titled “From a hand-rolled class function to a variant table”

Look at what your two unions actually imply. variant has three members (primary, destructive, ghost) and size has three (sm, md, lg). That isn’t three class strings plus three class strings. It’s a base, plus one string per variant, plus one string per size, all combined together. Three times three is nine possible buttons on screen, and buttonClasses is the function that has to return the right pile of classes for any of them.

You could write that by hand, with a nested ternary that branches on variant and then on size, or a lookup object like { primary: '...', destructive: '...' } indexed by the prop. Both work for a while. The trouble shows up the day a designer adds an outline variant: now you’re editing the ternary, the size logic, the prop union, and the default, four places by hand, with nothing keeping them in sync. That drift is what you want to avoid. The fix is a small library built for exactly this one job, class-variance-authority , universally imported as cva.

cva is the buttonClasses function you stubbed out. Instead of writing the lookup logic yourself, you declare the variant table as data and cva hands you back the function. Here’s the call that replaces the placeholder. Read it once top to bottom, then we’ll walk the four parts.

import { cva } from 'class-variance-authority';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
size: {
sm: 'h-8 px-3',
md: 'h-9 px-4',
lg: 'h-10 px-6',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
},
);

The base string, which is the first argument. These classes apply to every button no matter the variant: the flex layout, the rounding, the font, the disabled and focus states. Anything that never changes between a primary and a destructive button lives here, written once.

import { cva } from 'class-variance-authority';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
size: {
sm: 'h-8 px-3',
md: 'h-9 px-4',
lg: 'h-10 px-6',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
},
);

The variants object, which is the table itself. It’s keyed by prop name (variant, size), and under each key, every union member maps to the classes that member adds. This is the union you wrote in the typed-props lesson, primary | destructive | ghost, except now each member carries its own styling instead of living as a bare string somewhere else.

import { cva } from 'class-variance-authority';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
size: {
sm: 'h-8 px-3',
md: 'h-9 px-4',
lg: 'h-10 px-6',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
},
);

defaultVariants, the runtime fallback. When a caller writes <Button> with no variant, cva fills in primary, and with no size, md. This is the exact same job the destructure defaults did earlier (variant = 'primary'), moved into the table so one place says “the default button is a medium primary.”

import { cva } from 'class-variance-authority';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
size: {
sm: 'h-8 px-3',
md: 'h-9 px-4',
lg: 'h-10 px-6',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
},
);

The return value. cva(...) hands back a function. Calling buttonVariants({ variant, size }) resolves the combination into one joined string: the base, plus that variant’s classes, plus that size’s classes. That’s the same call shape as the old buttonClasses({ variant, size }), so the JSX that consumes it doesn’t change at all. You swapped the implementation, and the call site never noticed.

1 / 1

Notice the export is named buttonVariants, not buttonClasses. That’s the shadcn convention, and it’s worth adopting now so the name matches every file you’ll read later. Mentally, map it straight onto the placeholder you left behind: buttonVariants is buttonClasses, finally built.

The thing to hold onto is that cva isn’t magic; it’s a table lookup. Scrub through the next figure to see exactly what buttonVariants({ variant: 'destructive', size: 'lg' }) does.

Base — on every cell
inline-flex items-center justify-center rounded-md text-sm font-medium
sm
md
lg
primary
destructive
ghost
Pick a (variant, size) pair to resolve one cell into a class string.
The base classes apply to every cell. The table is just a map from a (variant, size) pair to the extra classes that pair adds: nine possible buttons over one shared base.
Base — on every cell
inline-flex items-center justify-center rounded-md text-sm font-medium
sm
md
lg
primary
destructive
this one
ghost
buttonVariants({ variant: 'destructive', size: 'lg' })
inline-flex items-center justify-center rounded-md text-sm font-medium + bg-destructive text-destructive-foreground + h-10 px-6
variant row size column

buttonVariants({ variant: 'destructive', size: 'lg' }) lights one cell. The result is the base plus that row’s classes plus that column’s classes, joined into a single string. Look up the cell, then concatenate: that’s all cva does.

There were two pieces of hand-work in that first button, not one. You hand-rolled buttonClasses, which cva just replaced. But you also hand-typed the props, writing variant?: 'primary' | 'destructive' | ... and size?: 'sm' | ... as literal unions in the type Props. That’s a second source of truth, and it drifts for the same reason the class logic did: add an outline to the cva table and the type still only knows about three variants, so a caller who passes variant="outline" gets a red squiggle for a variant that actually works.

cva solves this too. It ships a type helper, VariantProps , that reads your cva call and produces the prop types from it. VariantProps<typeof buttonVariants> is exactly { variant?: 'primary' | 'destructive' | 'ghost'; size?: 'sm' | 'md' | 'lg' }, derived rather than written. The table becomes the single source of truth for both the classes and their types. Here’s the before and after.

type Props = ComponentProps<'button'> & {
variant?: 'primary' | 'destructive' | 'ghost';
size?: 'sm' | 'md' | 'lg';
leftIcon?: ReactNode;
};

Two sources of truth. The union lives here and the matching class strings live in the cva table. Add a variant in one and you have to remember the other, or they silently disagree.

That second tab is the real, final props line for the rest of the lesson, so it’s worth reading slowly. The tooltips below cover the two pieces that are new:

type Props = ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
leftIcon?: ReactNode;
};

Two things to register. leftIcon?: ReactNode is the prop-as-slot you added in Children and compound components, and it stays. asChild?: boolean is new; it’s the whole second half of this lesson, previewed here so the props line you’re looking at is the one you’ll actually ship.

There is one small wrinkle worth noting before we move on. VariantProps types each variant as 'primary' | 'destructive' | ... | null | undefined, and that null branch is real. It’s the “explicitly unset” value, and defaultVariants is what resolves it back to a concrete variant at runtime. You’ll almost never write variant={null} yourself, but if you ever wonder why the type has a null in it, that’s the answer: it’s the unset slot the defaults fill.

Here’s the problem the lesson opened with, now that the styling is solid. Your <Button> paints a great-looking action. But the “Open dashboard” button has to navigate: it needs to be an <a> (a Next.js <Link>, specifically) so all that browser anchor behavior works. Cmd-click and middle-click open it in a new tab, it shows in the new-tab menu, and assistive tech announces it as a link. The visual is identical, but the element underneath has to be different. How do you let a <Button> be an anchor when the situation calls for it?

There are three obvious ways to do this, and all three are bad. Seeing exactly how each one fails is what makes the right answer feel inevitable. Each tab below is one losing approach.

const linkButtonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-md …',
{ variants: { variant: { primary: 'bg-primary …', /* …all of it, again… */ } } },
);
export const LinkButton = ({ variant, size, className, ...rest }: LinkButtonProps) => (
<a className={cn(linkButtonVariants({ variant, size }), className)} {...rest} />
);

Rebuilds the entire variant table for a second element. Every new variant, every padding tweak, every hover state now ships twice and has to stay identical in two files forever. The …all of it, again… comment captures the whole problem.

Step back and notice what all three have in common. They each make the component responsible for the element, whether by duplicating it, making it generic, or wrapping it. That’s the mistake. The move you already learned in the last lesson is to let the consumer bring the element, and have the component merge its classes and behavior onto whatever the consumer brought. You saw the raw machinery for that there: cloneElement and Children.map, which the course told you not to reach for directly because there’s a typed contract that does the same thing. This is that contract. It’s called asChild, and it settles the “button or anchor” question by composition rather than types: the caller hands in the element they want, and the component merges onto it. Here’s how it works.

Slot: merging props onto the consumer’s element

Section titled “Slot: merging props onto the consumer’s element”

The whole mechanism rests on one small component from Radix called Slot. Start with the one-sentence version and trust it as a black box for now: Slot takes exactly one child and merges its own props onto that child, rendering no wrapper of its own.

So this:

<Slot className="btn-classes" onClick={handleClick}>
<a href="/dashboard">Open</a>
</Slot>

renders this:

<a class="btn-classes" href="/dashboard" onclick="">Open</a>

The <Slot> disappears. Its className and onClick land on the <a>, and the <a>’s own href rides along untouched. Install it once with:

Terminal window
npm i @radix-ui/react-slot

The interesting question is what “merges its props onto the child” actually means when both Slot and the child have something to say about the same prop. Three cases are worth seeing at a glance, because they’re the cases your button hits in practice. The next figure puts the Slot’s props and the child’s props side by side and shows what comes out the other end.

Slot parent props
child <Link> its own props
rendered <a> what lands on it
className
"…button classes…"
"text-blue-600"
concatenated
…button classes… text-blue-600
onClick
{parentHandler}
{childHandler}
composed
parentHandler() childHandler()
ref
{ref}
forwarded
ref → the <a>
href
"/dashboard"
passthrough
"/dashboard"
<a> — the Slot is gone

Slot takes the parent props on the left and the child <Link>’s own props on the right and merges them per prop kind onto the rendered element. Read across a row: className is concatenated, handlers are composed (both fire), the ref is forwarded onto the child’s element, and everything else, like href, passes through untouched. Each surviving piece is tinted by where it came from: blue from the Slot, green from the child.

That diagram is the detail; you only need to recognize the rules, not memorize them, because in your button the parent props are the merged classes and the child is whatever element the caller passed. Two rows deserve a word. className is concatenated, so both the button’s classes and the child’s classes survive, which matters in a second when we talk about conflicts. Handlers are composed: if both the Slot and the child have an onClick, both run, so a caller can add their own click handler on the child without losing whatever the component wired up.

You can set the ref row aside for now. Slot forwards the parent’s ref down onto the child element, so <Button asChild ref={r}> puts r on the rendered <a> end to end. How refs travel through a component as a prop is the entire subject of the next lesson; here, just trust that it works.

Now for the author side: what you write inside the component to turn asChild on. It’s three lines added to the button you already have, and they’re the same three lines you’ll find in every shadcn primitive. Here’s the whole updated component; we’ll walk the parts that changed.

import { Slot } from '@radix-ui/react-slot';
import { cn } from '@/lib/utils';
type ButtonProps = ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
leftIcon?: ReactNode;
};
export const Button = ({
asChild = false,
variant,
size,
className,
leftIcon,
children,
...rest
}: ButtonProps) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size }), className)} {...rest}>
{leftIcon}
{children}
</Comp>
);
};

The contract: native button props, the variants derived from cva, plus asChild and leftIcon, all destructured at the parameter so each has a clear default. asChild defaults to false, giving a plain button unless the caller opts in.

import { Slot } from '@radix-ui/react-slot';
import { cn } from '@/lib/utils';
type ButtonProps = ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
leftIcon?: ReactNode;
};
export const Button = ({
asChild = false,
variant,
size,
className,
leftIcon,
children,
...rest
}: ButtonProps) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size }), className)} {...rest}>
{leftIcon}
{children}
</Comp>
);
};

The polymorphic switch, and the one new line. When asChild is true, Comp becomes Slot, which delegates everything to the child the caller passes. Otherwise Comp is the string 'button' and renders a real <button>. Note the capital C: JSX treats lowercase tags as DOM elements and capitalized names as components, so the variable has to be capitalized for <Comp> to work.

import { Slot } from '@radix-ui/react-slot';
import { cn } from '@/lib/utils';
type ButtonProps = ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
leftIcon?: ReactNode;
};
export const Button = ({
asChild = false,
variant,
size,
className,
leftIcon,
children,
...rest
}: ButtonProps) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size }), className)} {...rest}>
{leftIcon}
{children}
</Comp>
);
};

The merge, where the order is the point. buttonVariants(...) produces the variant classes, and className comes last so a caller’s override wins. This is the “className last” discipline from the typed-props lesson and the cn() contract you met in Tailwind, paying off together in one expression.

import { Slot } from '@radix-ui/react-slot';
import { cn } from '@/lib/utils';
type ButtonProps = ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
leftIcon?: ReactNode;
};
export const Button = ({
asChild = false,
variant,
size,
className,
leftIcon,
children,
...rest
}: ButtonProps) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size }), className)} {...rest}>
{leftIcon}
{children}
</Comp>
);
};

Everything else spreads onto <Comp>: onClick, disabled, type, aria-*, and href when the child is a link. The ref rides in here too as a regular prop (the next lesson is all about how that works); it just lands on the right element with no ceremony.

1 / 1

That’s it. One ternary picks the element, cn merges the classes, and the rest spreads through. Now for the consumer side: what the caller writes, and what the DOM actually becomes.

<Button asChild>
<Link href="/dashboard">Open dashboard</Link>
</Button>
// renders:
// <a class="…button classes…" href="/dashboard">Open dashboard</a>

Read that output again. The <button> is gone. There is no button element in the DOM; an <a> renders in its place, wearing all of the button’s classes and carrying the <Link>’s href. That’s the payoff: same look, correct element, one prop.

Two author-side things to keep in mind. First, Slot expects exactly one child element; hand it a fragment or two siblings and it throws, because there’s no single element to merge onto. (When you genuinely need multiple children inside an asChild slot there’s an escape called Slot.Slottable; recognize the name and reach for it the day you need it.) Second, when asChild is true your component’s own <button> never renders, so its defaults don’t either: no default type, none of its baked-in attributes. The child element owns its own semantics now. That follows directly from the design, but it’s easy to forget in practice.

There’s a reason the merge uses cn() and not a plain 'a' + ' ' + 'b' string join, and it only becomes obvious now that a caller can override variant classes. Picture this call:

<Button variant="primary" className="bg-destructive">Delete</Button>

The caller wants this specific primary-shaped button painted destructive-red. buttonVariants({ variant: 'primary' }) emits bg-primary, and the caller passes bg-destructive. If you joined those with a plain string concat, both bg-primary and bg-destructive ship to the element. Two background utilities then compete, and which one wins is decided by their order in the compiled CSS file rather than by your call. That’s a coin flip dressed up as code.

cn() is twMerge(clsx(...)), the helper you built back in Composing classes with cn(), and the tailwind-merge piece exists for exactly this. It knows bg-primary and bg-destructive target the same CSS property, so it keeps the last one and drops the other from the string entirely. Because className is your last argument, the caller’s bg-destructive wins deterministically and bg-primary never reaches the DOM.

className={`${buttonVariants({ variant: 'primary' })} ${className}`}
// ships: "… bg-primarybg-destructive" ← both classes, cascade decides

Both classes ship. The element ends up with bg-primary bg-destructive, and the winner is whichever appears later in the compiled stylesheet. That’s brittle and invisible.

The internals of how tailwind-merge recognizes that two classes conflict were the Tailwind chapter’s job. What’s new here is why it’s not optional once you ship a component whose variant classes a caller can override. The variant table makes overrides routine, so the conflict resolver has to be in the path.

Match the element to the behavior, not the look

Section titled “Match the element to the behavior, not the look”

One rule keeps asChild from creating accessibility problems, and it’s the most common way the feature gets misused. The failure it guards against is invisible in a screenshot and only surfaces for the users who depend on the missing behavior, so it’s worth treating on its own.

The rule: use asChild to vary the element so it matches the semantics of the behavior, never to make one element impersonate another. Two concrete consequences show what that distinction buys you.

A button that navigates should be an anchor:

<Button asChild>
<Link href="/invoices/42">View invoice</Link>
</Button>

This renders an <a> that looks like a button but is a link. Cmd-click and middle-click open it in a new tab. It shows up in the “open in new tab” menu. A screen reader announces “link, View invoice.” That’s all correct, because the behavior is navigation and the element is an anchor.

Now the same thing done wrong, a real <button> styled to look like a link, wired to navigate in its click handler:

<button onClick={() => router.push('/invoices/42')}>View invoice</button>

It looks like a link, but it is a button. Cmd-click does nothing. There’s no “open in new tab.” A screen reader announces “button,” not “link,” so a user navigating by links never finds it. A keyboard user can’t open it in a background tab. Every one of those is a real regression, and none of them shows up in a screenshot, which is exactly why this mistake ships.

The inverse misuse is just as tempting and just as wrong: reaching for asChild to swap kinds of element, making a <Button> render a <div>, only to dodge a default style or strip some behavior you didn’t want. That throws away the semantics and the keyboard handling that came with the real element, so leave it alone. The element follows the behavior: an action is a <button>, navigation is an <a> or <Link>. asChild exists to honor that distinction across different visual treatments, not to paper over it.

Test the instinct directly. For each scenario, which element should the component render?

Every card below renders as the same blue rounded <Button>. For which ones should asChild swap in a <Link> — so the element underneath is really an <a>? Select all that apply.

A toolbar control, drawn as a flat text link, that deletes the selected row.
A pricing card that, clicked anywhere, opens that plan’s detail page at /plans/pro.
A “Save changes” control that submits the surrounding settings form.
A “Read the changelog” control, drawn as a solid filled button, that loads /changelog.

You saw the as prop rejected back in the three-bad-options pass, but it’s worth settling the decision cleanly, because you’ll meet as in real codebases and should be able to explain why the course doesn’t use it. Several mature libraries ship it: Chakra and Mantine call it as, MUI calls it component. So <Button as={Link}> is a real, widely used pattern, not a strawman. Here’s the contrast that settles it.

With an as prop, the component owns the polymorphism. To make the types honest, it has to retype its entire prop surface through whatever element you passed, using conditional generics. In practice the inferred child props degrade, the error messages become unreadable, and you’ve taken on an extra typed surface to maintain forever. With asChild and Slot, the consumer owns the element. The child is just their own JSX, fully typed by its own props: a <Link> is typed as a <Link>, a <button> as a <button>, and your component only contributes classes and behavior on top. Nothing gets retyped, so nothing degrades. The types stay clean because you never asked one component to become every possible element.

This is the same call the chapter has been making since the last lesson, compose rather than configure, applied this time to the element itself. The consumer brings the piece that varies, and your component brings the part that stays the same. You don’t need to see the as implementation to know you don’t want to own it.

Here is everything assembled in one clean file, the version you take with you. Note the filename, button.tsx, kebab-case and exporting Button, matching the project’s file convention.

components/ui/button.tsx
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import type { ComponentProps, ReactNode } from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
ghost: 'hover:bg-accent hover:text-accent-foreground',
},
size: {
sm: 'h-8 px-3',
md: 'h-9 px-4',
lg: 'h-10 px-6',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
},
);
type ButtonProps = ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
leftIcon?: ReactNode;
};
export const Button = ({
asChild = false,
variant,
size,
className,
leftIcon,
children,
...rest
}: ButtonProps) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp className={cn(buttonVariants({ variant, size }), className)} {...rest}>
{leftIcon}
{children}
</Comp>
);
};

That’s the whole thing. A typed function whose classes come from a variant table (cva), whose element the consumer can substitute (Slot via asChild), with the caller’s className always winning the merge (cn). You’ll see this file again, nearly character-for-character, behind every shadcn primitive later in the course, and now you can read those files because you just wrote one.

Before you go, walk the steps the component takes for a single call, to be sure the assembly fits together.

Trace the call below through the canonical Button, top to bottom. Order the five steps from the first thing the component does to the element the DOM ends up with. Drag the items into the correct order, then press Check.

<Button asChild variant="destructive" className="w-full">
<Link href="/x">Go</Link>
</Button>
asChild is true, so Comp becomes Slot rather than 'button'.
buttonVariants({ variant: 'destructive', size: undefined }) resolves to the base, plus the destructive classes, plus the default md size from defaultVariants.
cn(...) merges those variant classes with the caller’s className="w-full" — caller last, so w-full survives.
Slot merges that combined className (and {...rest}) onto its single child, the <Link>.
The DOM renders <a href="/x" class="… bg-destructive … w-full">Go</a> — no <button> element at all.