React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

shadcnbuttonvariantsloadingicons

Button

shadcn Button component — variants, sizes, loading states, and icon buttons.

Recipe

Quick-reference recipe card — copy-paste ready.

npx shadcn@latest add button
import { 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-label for accessibility
  • Buttons with leading and trailing icons
  • asChild to 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
  • asChild uses Radix's Slot component 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 the className prop
  • 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

  • asChild removes the button element — The child element receives all button props but is not wrapped in a <button>. Fix: Ensure the child can accept onClick, className, and other button props.

  • Icon buttons need aria-label — An icon-only button has no visible text. Fix: Always add aria-label for screen readers.

  • disabled prevents all events — Unlike aria-disabled, the native disabled attribute removes the button from tab order. Fix: Use aria-disabled if you need the button to remain focusable while inactive.

  • Button inside a form submits by default — Buttons without type default to type="submit". Fix: Use type="button" for non-submit buttons inside forms.

  • Overriding variantsclassName="bg-red-500" on a variant="default" button may not win due to specificity. Fix: The cn() utility handles this via tailwind-merge.

Alternatives

AlternativeUse WhenDon't Use When
Native <button>You need a one-off button without the variant systemYou want consistent styling across the app
Radix ToggleYou need a toggle button with pressed stateA regular button suffices
<a> styled as buttonThe action navigates to a URLThe action triggers a client-side event
Icon button librariesYou need specialized icon button behaviorshadcn 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 actions
  • destructive — delete or dangerous actions
  • outline — bordered, low-emphasis actions
  • ghost — minimal style, often for toolbars
  • link — 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 Slot component to merge the Button's props onto its single child element
  • The <button> element is removed; the child receives onClick, 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 by tailwind-merge) merges and deduplicates Tailwind classes
Gotcha: Why might a Button inside a form submit unexpectedly?
  • Buttons without an explicit type attribute default to type="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" to size="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 className set the same property
  • The cn() utility uses tailwind-merge to resolve this, so it should work -- but only if you pass through cn()
  • If you bypass cn(), the last class in the stylesheet wins, which may not be your override
  • Setup — installing shadcn/ui
  • Dialog — buttons that trigger dialogs
  • Form — submit buttons in forms
  • Toast — button actions with toast feedback