Button
shadcn Button component — variants, sizes, loading states, and icon buttons.
Recipe
Quick-reference recipe card — copy-paste ready.
npx shadcn@latest add buttonimport { Button } from "@/components/ui/button";
// Variants
<Button variant="default">Primary</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="destructive">Delete</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
// Sizes
<Button size="sm">Small</Button>
<Button size="default">Default</Button>
<Button size="lg">Large</Button>
<Button size="icon">Icon</Button>
// As a link
<Button asChild>
<a href="/about">About</a>
</Button>
// Disabled
<Button disabled>Disabled</Button>When to reach for this: For every clickable action in your app — the Button component provides consistent styling, accessibility, and variant support.
Working Example
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Loader2, Plus, Trash2, Download, ExternalLink } from "lucide-react";
export function ButtonShowcase() {
const [loading, setLoading] = useState(false);
async function handleClick() {
setLoading(true);
await new Promise((r) => setTimeout(r, 2000));
setLoading(false);
}
return (
<div className="space-y-6">
{/* Loading button */}
<div className="flex gap-3">
<Button onClick={handleClick} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{loading ? "Saving..." : "Save Changes"}
</Button>
</div>
{/* Icon buttons */}
<div className="flex gap-2">
<Button size="icon" variant="outline" aria-label="Add item">
<Plus className="h-4 w-4" />
</Button>
<Button size="icon" variant="destructive" aria-label="Delete item">
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Button with icon and text */}
<div className="flex gap-3">
<Button>
<Download className="mr-2 h-4 w-4" />
Download
</Button>
<Button variant="outline" asChild>
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
Visit Site
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</div>
{/* Button group */}
<div className="inline-flex rounded-md shadow-sm">
<Button variant="outline" className="rounded-r-none border-r-0">Left</Button>
<Button variant="outline" className="rounded-none border-r-0">Center</Button>
<Button variant="outline" className="rounded-l-none">Right</Button>
</div>
{/* Full width */}
<Button className="w-full" size="lg">
Full Width Button
</Button>
</div>
);
}What this demonstrates:
- Loading state with spinner icon and disabled state
- Icon-only buttons with
aria-labelfor accessibility - Buttons with leading and trailing icons
asChildto render as an anchor tag- Button group with border manipulation
- Full-width button
Deep Dive
How It Works
- Button uses
class-variance-authority(cva) to define variant and size combinations asChilduses Radix'sSlotcomponent to merge props onto the child element- Each variant maps to a set of Tailwind classes including hover, focus, and disabled states
- The
cn()utility allows overriding any default class via theclassNameprop - Button renders a native
<button>by default, inheriting all HTML button attributes
Variations
Custom variant via cva:
// components/ui/button.tsx — add a custom variant
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible: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",
// ... other variants
success: "bg-green-600 text-white hover:bg-green-700",
warning: "bg-amber-500 text-white hover:bg-amber-600",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
xs: "h-7 rounded px-2 text-xs",
},
},
}
);Confirm-to-delete pattern:
function ConfirmDeleteButton({ onConfirm }: { onConfirm: () => void }) {
const [confirming, setConfirming] = useState(false);
if (confirming) {
return (
<div className="flex gap-2">
<Button variant="destructive" size="sm" onClick={onConfirm}>
Confirm
</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirming(false)}>
Cancel
</Button>
</div>
);
}
return (
<Button variant="outline" size="sm" onClick={() => setConfirming(true)}>
<Trash2 className="mr-2 h-3 w-3" /> Delete
</Button>
);
}Submit button with useFormStatus:
"use client";
import { useFormStatus } from "react-dom";
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{pending ? "Submitting..." : "Submit"}
</Button>
);
}TypeScript Notes
// ButtonProps type
import { Button, type ButtonProps } from "@/components/ui/button";
// Variant type extraction
import { type VariantProps } from "class-variance-authority";
type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
// "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"
// Polymorphic with asChild
<Button asChild>
<Link href="/about">About</Link> {/* Next.js Link */}
</Button>Gotchas
-
asChildremoves the button element — The child element receives all button props but is not wrapped in a<button>. Fix: Ensure the child can acceptonClick,className, and other button props. -
Icon buttons need
aria-label— An icon-only button has no visible text. Fix: Always addaria-labelfor screen readers. -
disabledprevents all events — Unlikearia-disabled, the nativedisabledattribute removes the button from tab order. Fix: Usearia-disabledif you need the button to remain focusable while inactive. -
Button inside a form submits by default — Buttons without
typedefault totype="submit". Fix: Usetype="button"for non-submit buttons inside forms. -
Overriding variants —
className="bg-red-500"on avariant="default"button may not win due to specificity. Fix: Thecn()utility handles this viatailwind-merge.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
Native <button> | You need a one-off button without the variant system | You want consistent styling across the app |
| Radix Toggle | You need a toggle button with pressed state | A regular button suffices |
<a> styled as button | The action navigates to a URL | The action triggers a client-side event |
| Icon button libraries | You need specialized icon button behavior | shadcn Button with size="icon" covers it |
FAQs
What are the available Button variants and when should you use each one?
default— primary actions (submit, save)secondary— less prominent actionsdestructive— delete or dangerous actionsoutline— bordered, low-emphasis actionsghost— minimal style, often for toolbarslink— styled as a hyperlink
How do you render a Button as a link or Next.js Link component?
Use the asChild prop to delegate rendering to the child element:
<Button asChild>
<a href="/about">About</a>
</Button>How do you implement a loading state on a Button?
<Button onClick={handleClick} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{loading ? "Saving..." : "Save Changes"}
</Button>What does the asChild prop actually do under the hood?
- It uses Radix's
Slotcomponent to merge the Button's props onto its single child element - The
<button>element is removed; the child receivesonClick,className, etc. - This lets you render as
<a>,<Link>, or any element while keeping Button styling
How do you create a custom Button variant like "success" or "warning"?
Add the variant to the buttonVariants cva definition in components/ui/button.tsx:
success: "bg-green-600 text-white hover:bg-green-700",
warning: "bg-amber-500 text-white hover:bg-amber-600",What library does the Button component use internally for variant management?
class-variance-authority(cva) defines variant and size combinations- The
cn()utility (powered bytailwind-merge) merges and deduplicates Tailwind classes
Gotcha: Why might a Button inside a form submit unexpectedly?
- Buttons without an explicit
typeattribute default totype="submit" - Fix: use
type="button"for non-submit buttons inside forms
How do you build a button group with connected borders?
<div className="inline-flex rounded-md shadow-sm">
<Button variant="outline" className="rounded-r-none border-r-0">Left</Button>
<Button variant="outline" className="rounded-none border-r-0">Center</Button>
<Button variant="outline" className="rounded-l-none">Right</Button>
</div>Why must icon-only buttons always have an aria-label?
- Icon-only buttons have no visible text for screen readers
- Without
aria-label, assistive technology announces the button with no meaningful name - Always add
aria-label="Description"tosize="icon"buttons
How do you type-extract the Button variant as a TypeScript union?
import { type VariantProps } from "class-variance-authority";
type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
// "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"How does useFormStatus integrate with the Button for server action forms?
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button type="submit" disabled={pending}>
{pending ? "Submitting..." : "Submit"}
</Button>
);
}Gotcha: Why might className="bg-red-500" not override a variant's background color?
- Tailwind class specificity can cause conflicts when both the variant and
classNameset the same property - The
cn()utility usestailwind-mergeto resolve this, so it should work -- but only if you pass throughcn() - If you bypass
cn(), the last class in the stylesheet wins, which may not be your override