Hands-On Practice: Building a Button Component from Scratch
What you learn in this chapter
You stack the techniques you have learned one by one to complete a Button component from scratch.
Step 1: a plain button
↓
Step 2: + styling with Tailwind (design tokens) ← the Tailwind chapter, the design tokens chapter
↓
Step 3: + variants with cva ← the cva chapter
↓
Step 4: + class merging with cn / ...props (ref pass-through too) ← the cn chapter, the ref chapter
↓
Step 5: + asChild (Slot) ← the Slot chapter
↓
Step 6: + TypeScript types ← the TypeScript chapter
↓
Complete (the Button from the final-form chapter)
Step 1: the simplest button
// src/components/ui/button.tsx
function Button({ children }) {
return <button>{children}</button>
}
export { Button }
<Button>Click me</Button>
At this stage, the editor shows an "implicit any" type error (TS7031), which is expected. You add types in Step 6. You can verify the behavior in the browser without any problem.
Step 2: styling with Tailwind (the Tailwind chapter, the design tokens chapter)
You use the design tokens (such as bg-primary) you learned in the design tokens chapter.
function Button({ children }) {
return (
<button className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50">
{children}
</button>
)
}
export { Button }
At this point, the classes are long, and increasing the looks (such as a red button) is tedious. This is the reason to introduce cva.
Step 3: variant management with cva (the cva chapter)
You gather variants declaratively with cva. All the colors are design tokens.
// src/components/ui/button.tsx
import { cva } from "class-variance-authority"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3",
lg: "h-10 px-6",
icon: "size-9",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)
function Button({ children, variant, size }) {
return <button className={buttonVariants({ variant, size })}>{children}</button>
}
export { Button, buttonVariants }
<Button>Default</Button>
<Button variant="destructive">Delete</Button>
<Button variant="outline" size="lg">Large outline button</Button>
In the Steps after this, the buttonVariants definition above does not change. Only the Button function part changes, so focus there.
Step 4: class merging with cn + ...props (the cn chapter, the ref chapter)
You make className overriding safe with cn, and receive the remaining attributes all at once with ...props.
import { cn } from "@/lib/utils"
function Button({ className, variant, size, ...props }) {
return (
<button className={cn(buttonVariants({ variant, size, className }))} {...props} />
)
}
By spreading ...props onto <button>, onClick, disabled, and ref too are now passed through transparently. In React 19, ref is also a normal prop, so this is enough to make it work (the ref chapter).
function FocusExample() {
const buttonRef = useRef<HTMLButtonElement>(null)
useEffect(() => buttonRef.current?.focus(), [])
return <Button ref={buttonRef}>Focused automatically</Button>
}
<Button className="mt-4 w-full">Add margin and width</Button>
Step 5: the asChild pattern (the Slot chapter)
With radix-ui's Slot, you make the element swappable.
import { Slot } from "radix-ui"
function Button({ className, variant, size, asChild = false, ...props }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
<Button asChild>
<a href="/profile">Profile</a>
</Button>
Step 6: add TypeScript types to complete it (the TypeScript chapter)
Finally, you add types. This is the final form you saw in the final-form chapter.
// src/components/ui/button.tsx
import * as React from "react"
import { Slot } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3",
lg: "h-10 px-6",
icon: "size-9",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)
type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
Testing the completed Button
// src/App.tsx
import { Button } from "@/components/ui/button"
function App() {
return (
<div className="space-y-6 p-8">
<section className="space-x-2">
<h2 className="mb-2 text-lg font-semibold">Variants</h2>
<Button variant="default">Default</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
</section>
<section className="space-x-2">
<h2 className="mb-2 text-lg font-semibold">Sizes</h2>
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon">🔍</Button>
</section>
<section className="space-x-2">
<h2 className="mb-2 text-lg font-semibold">Custom / asChild / disabled</h2>
<Button className="w-full">Full width</Button>
<Button variant="outline" asChild>
<a href="https://example.com" target="_blank" rel="noopener">
A button that becomes a link
</a>
</Button>
<Button disabled>Disabled</Button>
</section>
</div>
)
}
export default App
For colors like bg-primary to display, the design tokens from the design tokens chapter must be defined in src/index.css (if you used npx shadcn@latest init, they are included automatically).
Exercises
Exercise 1: add a new variant
Let's add a success (greenish) variant. Ideally, define a --success token using the approach from the design tokens chapter and use bg-success text-success-foreground.
Sample answer
First, define the token in src/index.css and register it with Tailwind using @theme inline (the 3 stages from the design tokens chapter).
:root {
--success: oklch(0.6 0.13 160);
--success-foreground: oklch(0.985 0 0);
}
.dark {
--success: oklch(0.55 0.13 160);
--success-foreground: oklch(0.985 0 0);
}
@theme inline {
--color-success: var(--success);
--color-success-foreground: var(--success-foreground);
}
Next, add one line to the variant of buttonVariants.
success: "bg-success text-success-foreground hover:bg-success/90",
<Button variant="success">Saved</Button>
The key point is the @theme inline registration. Without it, the bg-success class is not generated.
Exercise 2: add a new size
Let's add an xs (very small) size.
Sample answer
Just add one line to the size of buttonVariants.
size: {
xs: "h-7 px-2 text-xs",
sm: "h-8 px-3",
default: "h-9 px-4 py-2",
lg: "h-10 px-6",
icon: "size-9",
},
<Button size="xs">Very small</Button>
With h-7 (28px) / px-2 (8px) / text-xs, it is one size smaller than sm.
Exercise 3: add a loading state
Let's add an isLoading prop and display a spinner while loading.
Sample answer
Add the following 4 things to the Button you completed in Step 6. For the spinner, use Loader2 (a spinning icon) from shadcn/ui's standard icon set lucide-react, so add it if you do not have it.
npm install lucide-react
① Add isLoading to the type
type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
isLoading?: boolean // ← added
}
② Extract children with destructuring
You want to place the spinner before children, so receive children individually, which you previously included in ...props.
function Button({
className, variant, size, asChild = false,
isLoading = false, // ← added
children, // ← extract from ...props
...props
}: ButtonProps) {
③ Tie disabled to isLoading
To prevent double submission while loading, automatically set disabled. By writing it after {...props}, you can OR-override the caller's disabled with isLoading (since later-written JSX props win; in the reverse order, {...props} would override it).
<Comp {...props} disabled={isLoading || props.disabled}>
④ Place the spinner before children
{isLoading && <Loader2 className="mr-2 size-4 animate-spin" />}
{children}
Here is the final form combining all of the above (the comment numbers correspond to each step above).
import { Loader2 } from "lucide-react"
type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
isLoading?: boolean // ①
}
function Button({
className, variant, size, asChild = false,
isLoading = false, // ①
children, // ②
...props
}: ButtonProps) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
disabled={isLoading || props.disabled} // ③ disable while loading
>
{isLoading && <Loader2 className="mr-2 size-4 animate-spin" />} {/* ④ the spinner */}
{children}
</Comp>
)
}
<Button isLoading>Submitting…</Button>
Using asChild and isLoading at the same time violates the "single child element" constraint that Slot requires (because the spinner + children become two elements). If you want both, you need a design that includes the spinner on the child element side.
Chapter summary
You built the Button from scratch in the following order.
- a plain button → 2. Tailwind (tokens) → 3. cva → 4. cn and
...props(ref pass-through) → 5. Slot (asChild) → 6. types
Without using forwardRef, you completed it with React 19's simple style.
In the next chapter, you apply the patterns you gained here to other components.