Polymorphism with Slot and CVA
Build a shadcn-style button whose variant styles come from class-variance-authority and whose element the caller can swap through Radix Slot and asChild.
A <Button> leads two lives. Half the time it’s an action, submit the form, delete the row, open the menu, and a real <button> is the right element. The other half it’s navigation, “Open dashboard” or “View invoice,” and the right element is an <a>. The browser bakes a contract into anchors that a <button> can’t fake: Cmd-click or middle-click opens a new tab, and a screen reader announces “link, Open dashboard.” Same blue rounded box on screen, two different things underneath, yet the table of classes that paints the box has nothing to do with which element wins. 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 contract, variant and size as string-literal unions, and left a buttonClasses({ variant, size }) placeholder unbuilt. In Children and compound you learned the instinct that closes the gap: compose, don’t configure. This lesson builds the two tools that finish it. 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. The result is the <Button> that sits, nearly character-for-character, behind every shadcn primitive.
From a hand-rolled class function to a variant table
Section titled “From a hand-rolled class function to a variant table”Your two unions are each three members wide: variant is primary | destructive | ghost, size is sm | md | lg. The classes aren’t three strings plus three strings. They’re a base, plus one string per variant, plus one string per size, combined. That’s nine possible buttons on screen, and buttonClasses is the function that returns the right set for any of them.
You could write that by hand with a nested ternary, or a lookup object indexed by the prop. Both work until a designer adds an outline variant: now you edit the ternary, the size logic, the prop union, and the default by hand, with nothing keeping the four in sync. A small library exists for exactly this job, class-variance-authority , imported everywhere as cva.
cva is the buttonClasses function you stubbed out. You declare the variant table as data and cva hands back the function. Here’s the call that replaces the placeholder, in 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, the first argument. These classes apply to every button regardless of variant: flex layout, rounding, font, 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, the table itself. It’s keyed by prop name (variant, size), and under each key every union member maps to the classes it adds. This is the primary | destructive | ghost union from the typed-props lesson, except each member now carries its own styling instead of sitting as a bare string elsewhere.
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. A <Button> with no variant gets primary, with no size gets md. This is the 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: base, plus that variant’s classes, plus that size’s classes. Same call shape as the old buttonClasses({ variant, size }), so the JSX consuming it doesn’t change.
The export is named buttonVariants, not buttonClasses. That’s the shadcn convention; adopt it now so the name matches every file you read later. It’s the placeholder you left behind, finally built.
cva is a table lookup, nothing more. Scrub through the figure to see what buttonVariants({ variant: 'destructive', size: 'lg' }) does.
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 one string. Look up the cell, then concatenate: that’s all cva does.
Typing the variants for free
Section titled “Typing the variants for free”The first button had two pieces of hand-work, not one. cva already replaced the hand-rolled buttonClasses. But the props were hand-typed too: variant?: 'primary' | 'destructive' | ... and size?: 'sm' | ... as literal unions in type Props. That’s a second source of truth, and it drifts the same way the class logic did. Add outline to the cva table and the type still knows only three variants, so a caller who passes variant="outline" gets a red squiggle for a variant that actually works.
cva solves this too, with a type helper, VariantProps , that reads your cva call and derives 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.
type Props = ComponentProps<'button'> & { variant?: 'primary' | 'destructive' | 'ghost'; size?: 'sm' | 'md' | 'lg'; leftIcon?: ReactNode;};Two sources of truth. The union lives here, 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.
type Props = ComponentProps<'button'> & VariantProps<typeof buttonVariants> & { asChild?: boolean; leftIcon?: ReactNode; };One source of truth. variant and size come straight from the cva call. Add outline to the table and this type updates with no edit here.
That second tab is the final props line for the rest of the lesson. The tooltips cover the two new pieces:
type Props = ComponentProps<'button'> & VariantProps<typeof buttonVariants> & { asChild?: boolean; leftIcon?: ReactNode; };leftIcon?: ReactNode is the prop-as-slot from Children and compound, and it stays. asChild?: boolean is new: it’s the whole second half of this lesson, previewed here so the props line you’re reading is the one you’ll ship.
One wrinkle before moving on. VariantProps types each variant as 'primary' | 'destructive' | ... | null | undefined, and that null branch is real: it’s the “explicitly unset” value, which defaultVariants resolves back to a concrete variant at runtime. You’ll almost never write variant={null} yourself, but that null in the type is the unset slot the defaults fill.
Three failed ways to make a button render a link
Section titled “Three failed ways to make a button render a link”Here is 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 the browser’s anchor behavior works. Cmd-click and middle-click open it in a new tab, it appears in the new-tab menu, and assistive tech announces it as a link. The visual is identical; the element underneath has to differ. So how do you let a <Button> be an anchor when the situation calls for it?
There are three obvious ways, and all three are bad. Seeing how each one fails is what makes the right answer feel inevitable.
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, padding tweak, and hover state now ships twice and has to stay identical across two files forever. The …all of it, again… comment captures the whole problem.
// The shape libraries like Chakra and Mantine ship:type ButtonProps<E extends ElementType> = { as?: E; variant?: …;} & ComponentProps<E>; // ← retype every prop to whatever E is
<Button as={Link} href="/dashboard">Open</Button>;Works until the types don’t. This is a real, widely used pattern, not a strawman: Chakra and Mantine ship it as as, MUI as component. But it makes the component own the polymorphism — its ability to render as more than one underlying element — so to allow href only when as={Link} it has to retype its entire prop surface through the target element with conditional generics. The implementation takes several layers of dense TypeScript, the inferred props degrade, and the errors it produces are unreadable. We won’t build it; the sketch is enough to see the cost.
<a href="/dashboard"> <Button>Open</Button></a>Produces invalid interactive HTML. Nesting a <button> inside an <a> puts two interactive elements one inside the other, which the HTML spec forbids. Browsers render it unpredictably and screen readers announce a confusing double control. This breaks the exact rule you learned earlier: <button> and <a> are distinct interactive elements.
Notice what all three share: each makes the component responsible for the element, by duplicating it, making it generic, or wrapping it. That is the mistake. The move you already learned 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 there, cloneElement and Children.map, which the course told you not to reach for directly because a typed contract does the same job. This is that contract. It is called asChild, and it settles the “button or anchor” question by composition instead of types: the consumer owns the element, so the child stays their own fully-typed JSX and nothing degrades. The caller hands in the element they want, and the component merges onto it. Here is how it works.
Slot: merging props onto the consumer’s element
Section titled “Slot: merging props onto the consumer’s element”The mechanism rests on one Radix component: 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:
npm i @radix-ui/react-slotWhat does merging mean when both Slot and the child set the same prop? The next figure puts the Slot’s props and the child’s props side by side and shows what comes out.
Slot merges the parent props on the left with the child <Link>’s own props on the right, per prop kind. 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 piece is tinted by where it came from: blue from the Slot, green from the child.
Recognize the rules; you don’t need to memorize them. Two are worth a word. className is concatenated, so the button’s classes and the child’s classes both survive, which matters in a moment when we hit conflicts. Handlers are composed: if both the Slot and the child set onClick, both run, so a caller can add a click handler on the child without losing the one the component wired up.
For ref, Slot forwards the parent’s ref onto the child element, so <Button asChild ref={r}> puts r on the rendered <a>. How a ref travels through a component as a prop is the subject of the next lesson; here, trust that it works.
Wiring asChild into the Button
Section titled “Wiring asChild into the Button”Now the author side: what you write inside the component to turn asChild on. It’s three lines added to the button you already have.
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 from cva, plus asChild and leftIcon, each destructured with its own default. asChild defaults to false, so you get 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. When asChild is true, Comp becomes Slot and delegates everything to the child the caller passes; otherwise Comp is the string 'button' and renders a real <button>. The capital C matters: JSX reads lowercase tags as DOM elements and capitalized names as components, so the variable must 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 order is the point. buttonVariants(...) produces the variant classes, and className comes last so a caller’s override wins. This is the “className last” rule from the typed-props lesson meeting the cn() contract from Tailwind, 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 that lands on the right element with no ceremony (the next lesson covers how).
Now the consumer side: what the caller writes, and what the DOM becomes.
<Button asChild> <Link href="/dashboard">Open dashboard</Link></Button>
// renders:// <a class="…button classes…" href="/dashboard">Open dashboard</a>The <button> is gone. An <a> renders in its place, wearing all of the button’s classes and carrying the <Link>’s href: same look, correct element, one prop.
Two things to keep in mind. First, Slot expects exactly one child element; a fragment or two siblings throws, because there’s no single element to merge onto. (When you genuinely need multiple children inside an asChild slot, the escape hatch is Slot.Slottable — recognize the name and reach for it the day you need it.) Second, when asChild is true your component’s <button> never renders, so its defaults don’t either: no default type, none of its baked-in attributes. The child element owns its semantics now.
Why overriding a variant needs cn()
Section titled “Why overriding a variant needs cn()”A caller can now override variant classes, and that is exactly when the merge has to be cn() rather than a plain 'a' + ' ' + 'b' join. Picture this call:
<Button variant="primary" className="bg-destructive">Delete</Button>The caller wants a primary-shaped button painted destructive-red. buttonVariants({ variant: 'primary' }) emits bg-primary, and the caller passes bg-destructive. Join those with a string concat and both ship to the element. Two background utilities compete, and the winner is whichever lands later in the compiled CSS, not whichever you intended.
cn() is twMerge(clsx(...)), the helper you built in Composing with cn(). Its tailwind-merge step is what makes the override deterministic: it sees that bg-primary and bg-destructive set the same property, keeps the last one, and drops the other from the string. Since className is the last argument, bg-destructive wins and bg-primary never reaches the DOM.
className={`${buttonVariants({ variant: 'primary' })} ${className}`}// ships: "… bg-primary … bg-destructive" ← both classes, cascade decidesBoth classes ship. The element carries bg-primary bg-destructive, and the winner is whichever appears later in the compiled stylesheet. Brittle and invisible.
className={cn(buttonVariants({ variant: 'primary' }), className)}// ships: "… bg-destructive" ← caller wins, bg-primary droppedThe caller wins, every time. tailwind-merge spots the conflict, drops bg-primary, keeps bg-destructive, regardless of CSS order.
The variant table makes overrides routine, so the conflict resolver has to sit in the path: a component whose classes a caller can override needs cn(), not a string join.
Match the element to the behavior, not the styling
Section titled “Match the element to the behavior, not the styling”asChild is most often misused to change the underlying element, and the resulting bug is invisible in a screenshot: it only surfaces for the users who depend on the behavior you dropped.
The rule: use asChild so the element matches the semantics of the behavior, never to make one element impersonate another. Two examples show the difference.
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, and a screen reader announces “link, View invoice.” All correct, because the behavior is navigation.
Now the same thing done wrong, a real <button> styled as a link and wired to navigate in its click handler:
<button onClick={() => router.push('/invoices/42')}>View invoice</button>It looks like a link but is a button. Cmd-click does nothing, there’s no “open in new tab,” and a screen reader announces “button,” so a user navigating by links never finds it. Every one of those is a real regression, and none shows up in a screenshot, which is why the mistake ships.
The inverse misuse is just as wrong: reaching for asChild to make a <Button> render a <div>, only to dodge a default style or strip behavior you didn’t want. That throws away the semantics and keyboard handling the real element gave you. An action is a <button>, navigation is an <a> or <Link>; asChild honors that distinction across visual treatments, it doesn’t 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.
/plans/pro./changelog.<a> so Cmd-click and middle-click open a new tab and a screen reader announces “link.” Delete and Save changes both act on the current page (Save is a submit <button>), so they stay real <button>s no matter how they’re painted. Note the two traps cut opposite ways: the delete is styled flat like a link but is still an action, and the changelog is styled like a solid button but is still navigation. The paint never decides the element — the behavior does.The canonical button.tsx
Section titled “The canonical button.tsx”Everything assembled in one file. The filename is button.tsx, kebab-case and exporting Button, matching the project’s file convention.
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> );};A typed function whose classes come from a variant table (cva), whose element the consumer can swap (Slot via asChild), with the caller’s className winning the merge (cn). This same file sits behind every shadcn primitive you’ll meet later in the course.
Walk the steps the component takes for a single call to see how the pieces fit.
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>. <a href="/x" class="… bg-destructive … w-full">Go</a> — no <button> element at all. External resources
Section titled “External resources”The full cva API, including the compoundVariants you didn't need here but will the day a single combination needs a tweak.
The official Slot reference — the per-prop merge rules and Slot.Slottable for the multi-child case the lesson flagged.
The production Button you just rebuilt — the same cva table and asChild switch, with the Link navigation example spelled out.
How cn() resolves the bg-primary vs bg-destructive collision — the README shows exactly which utility wins and why.