| name | shadcn-syntax-button |
| description | Use when adding, customising, or debugging a shadcn ui Button, choosing between the six built-in variants (default / destructive / outline / secondary / ghost / link), choosing between the size variants (default / sm / lg / icon plus the v4-only xs / icon-xs / icon-sm / icon-lg), using the `asChild` Slot pattern to wrap a Next.js Link, a react-router Link, or any other custom anchor as the rendered button element, typing a Button-derived component with `VariantProps<typeof buttonVariants>`, building an icon-only button, building a loading button with a spinner, understanding the focus-ring difference between Tailwind v3 and v4 output, or diagnosing why a Button renders unstyled, ignores `className`, or crashes the page after `asChild` is added. Prevents the canonical Button-level failures : passing multiple children inside `<Button asChild>` (Slot.Root only accepts one), using `asChild` with a child that is not a forwardRef-compatible component, dropping an icon into `size="default"` instead of `size="icon"` and ending up with an over-padded square, hand-concatenating className strings instead of routing through `cn()` so that the caller's classes silently lose the twMerge conflict resolution, and replacing `<Button asChild><Link/></Button>` with `<button onClick={() => router.push()}>` which loses Next.js prefetch and breaks middle-click / keyboard accessibility. Covers the full buttonVariants cva() catalogue, the VariantProps typing pattern, the Slot.Root vs `"button"` swap that powers `asChild`, the Tailwind v3 (`focus-visible:ring-2 ring-ring ring-offset-2`) versus Tailwind v4 (`focus-visible:ring-[3px] ring-ring/50`) focus-ring shift, the icon-button + loading-button + linked-button recipes, RSC safety (Button is pure presentational, no `"use client"` directive needed), and how to extend buttonVariants with a project-specific variant such as `warning`. Keywords: shadcn button, Button component, button variant, button size, destructive button, outline button, ghost button, link variant, secondary button, icon button, size icon, asChild, Slot, Slot.Root, Button as Link, Button as anchor, Next.js Link button, react-router Link button, VariantProps, buttonVariants, cn helper, twMerge, Tailwind v3 ring, Tailwind v4 ring, focus-visible ring, how do I make a button, button not styled, button has wrong cursor, Button.Children Slot error, multiple children Slot crash, loading button, button with spinner, button disabled state, extend buttonVariants, custom variant warning, button data-slot, button data-variant.
|
| license | MIT |
| compatibility | Designed for Claude Code. Requires shadcn ui evergreen-2026. |
| metadata | {"author":"OpenAEC-Foundation","version":"1.0"} |
shadcn ui : Button Syntax
The Button component is shadcn's reference implementation of the cva-variant pattern. Every other variant-driven primitive (Badge, Alert, Toggle, ToggleGroup) follows the same shape. Mastering Button means mastering the shadcn variant API.
Quick Reference
Canonical call
import { Button } from "@/components/ui/button"
<Button variant="default" size="default">Save</Button>
variant and size are independent props. Both default to "default". Both are typed by VariantProps<typeof buttonVariants> so autocomplete reflects exactly the keys in the local buttonVariants cva definition.
Variant decision table
| User intent | Variant | Reason |
|---|
| Primary call-to-action ("Save", "Submit", "Continue") | default | Filled primary background ; highest visual weight |
| Irreversible / destructive action ("Delete", "Discard", "Reset") | destructive | Red fill signals risk ; pairs with confirmation dialog |
| Secondary action next to a primary ("Cancel", "Skip", "Back") | outline or secondary | Lower weight than default ; outline reads as "alternate", secondary reads as "muted-primary" |
| Tertiary action inside a busy UI (toolbar, table row) | ghost | No background until hover ; minimal chrome |
| Action that semantically IS a link (navigation, "Forgot password?") | link | Reads as text-link ; pairs with asChild + <Link> |
ALWAYS pick variant by user intent, NEVER by visual mood. If the action is destructive, variant="destructive" is non-optional, even if the surrounding design feels muted.
Size decision table
| User intent | Size | Notes |
|---|
| Default form / dialog button | default | 36px height (v4) ; pairs with Input of identical height |
| Compact toolbar / table action | sm | 32px height ; gap-1.5 ; rounded-md |
| Hero / marketing CTA | lg | 40px height ; px-6 |
| Icon-only button (toolbar, dialog close, table action) | icon | square 36px (v4) ; mandates a single icon child |
| Tailwind-v4-only : xs / icon-xs / icon-sm / icon-lg | xs, icon-xs, icon-sm, icon-lg | Extra granularity introduced in v4 ; NEVER assume these exist in a v3 project |
ALWAYS use size="icon" when the visible content is a single icon. NEVER drop an icon into size="default" : padding will dominate and accessibility-target rules become awkward.
Four invariants
- ALWAYS import from the local alias
@/components/ui/button. NEVER from shadcn-ui or @shadcn/ui. (See shadcn-core-architecture for the copy-not-install rationale.)
- ALWAYS pass exactly ONE child element when
asChild is true. NEVER two siblings ; Slot.Root crashes.
- ALWAYS let
className pass through unchanged. NEVER concatenate by hand : the Button source already calls cn(buttonVariants({ variant, size, className })), which routes through twMerge so caller classes win conflicts.
- ALWAYS treat Button as RSC-safe : the file has no
"use client" directive in either v3 or v4 form. NEVER add one just because the file uses an asChild boolean.
Decision Tree 1 : Which variant?
Q1. Is this the primary action of the surface (form, dialog, page hero)?
yes -> variant="default"
no -> Q2
Q2. Is this action destructive, irreversible, or data-losing?
yes -> variant="destructive" (ALWAYS pair with a confirmation Dialog)
no -> Q3
Q3. Is this action a secondary peer of a primary button (Cancel / Back / Skip)?
yes -> Q3a
no -> Q4
Q3a. Should the secondary read as "alternate path" -> variant="outline"
Should the secondary read as "muted primary" -> variant="secondary"
Q4. Is this a tertiary action embedded inside a busy region
(table row, toolbar, command palette, sidebar item)?
yes -> variant="ghost"
no -> Q5
Q5. Is the action SEMANTICALLY a link (navigation, "Forgot password?",
"Privacy policy", external URL)?
yes -> variant="link" + asChild + <Link>/<a>
no -> reconsider : default or outline is almost certainly right
Decision Tree 2 : asChild or no asChild?
Q1. Does the consumer need a NON-button element to be rendered
(an <a>, a Next.js <Link>, a react-router <Link>, a custom
forwardRef component)?
no -> NEVER use asChild ; just render <Button onClick={...}>
yes -> Q2
Q2. Is the child a single element that supports ref-forwarding
(Next.js <Link>, react-router <Link>, a native <a>, or a
React.forwardRef'd custom component)?
no -> ALWAYS wrap the non-forwardRef child first ; raw <div onClick>
swallows merged props. Or use <Button onClick={...}>.
yes -> Q3
Q3. Is there EXACTLY one child element (no siblings, no text node
next to an element)?
no -> ALWAYS collapse to one child ; Slot.Root throws on
multiple children. Compose icon + label INSIDE the child.
yes -> ALWAYS use <Button asChild><ChildElement>...</ChildElement></Button>
Decision Tree 3 : Icon-button or labelled button with an icon?
Q1. Is the visible content ONLY an icon (no text)?
yes -> size="icon" + lucide icon child + ALWAYS provide an
accessible name via aria-label
no -> Q2
Q2. Is the button text-with-leading-or-trailing icon?
yes -> size="default" (or sm/lg) ; drop the icon AND the label
as siblings inside the Button ; the cva base layer
includes `gap-2` and `[&_svg]:size-4` so the icon spaces
and sizes automatically
no -> reconsider : you have neither an icon nor a label
Patterns
Pattern 1 : Variant catalogue (verbatim from the canonical source)
The six variant values, with verbatim classes from apps/v4/registry/new-york-v4/ui/button.tsx :
| Variant | Verbatim classes |
|---|
default | bg-primary text-primary-foreground hover:bg-primary/90 |
destructive | bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 |
outline | border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50 |
secondary | bg-secondary text-secondary-foreground hover:bg-secondary/80 |
ghost | hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 |
link | text-primary underline-offset-4 hover:underline |
ALWAYS reference these by variant= prop. NEVER hand-copy the class strings : if shadcn upgrades them via the registry, your hand-copy diverges silently.
Pattern 2 : Size catalogue (verbatim, v4 only)
| Size | Verbatim classes | Use |
|---|
default | h-9 px-4 py-2 has-[>svg]:px-3 | Default form / dialog button |
xs | h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3 | Tiny inline action (v4 only) |
sm | h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5 | Compact toolbar / table action |
lg | h-10 rounded-md px-6 has-[>svg]:px-4 | Hero / marketing CTA |
icon | size-9 | Icon-only square button |
icon-xs | size-6 rounded-md [&_svg:not([class*='size-'])]:size-3 | Tiny icon-only (v4 only) |
icon-sm | size-8 | Small icon-only (v4 only) |
icon-lg | size-10 | Large icon-only (v4 only) |
For the legacy v3 size set, only default / sm / lg / icon exist, with different exact pixel values (see Pattern 5).
Pattern 3 : asChild Slot pattern
The canonical (v4) Button source contains exactly this pivot :
import { Slot } from "radix-ui"
function Button({ asChild = false, ...props }) {
const Comp = asChild ? Slot.Root : "button"
return <Comp data-slot="button" {...props} />
}
When asChild is true, the component RENDERS THE CHILD ELEMENT and merges its props (className, ref, event handlers, data attributes) onto that child. The result is a fully styled Button with a different underlying tag.
Canonical use : wrap a navigation primitive so the button styling lives on the link.
import Link from "next/link"
<Button asChild>
<Link href="/login">Login</Link>
</Button>
import { Link } from "react-router-dom"
<Button asChild variant="link">
<Link to="/forgot-password">Forgot password?</Link>
</Button>
ALWAYS exactly ONE child element inside <Button asChild>. NEVER siblings ; Slot.Root throws. Composition of icon + label happens INSIDE the child :
<Button asChild>
<Link href="/docs">
<BookOpen />
Docs
</Link>
</Button>
Section : Tailwind v3 vs Tailwind v4 ring utility
shadcn's Button ships two different focus-ring incantations depending on the registry style and the Tailwind version of the consumer project. Both are taken verbatim from the official registry source ; treat them as a single divergence point and emit the correct one.
v3 (default registry style, apps/v4/public/r/styles/default/button.json)
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2
Uses the v3 ring API : ring-2 (a 2px ring) + ring-offset-2 (a 2px offset) + named ring-ring token. The Button component is a React.forwardRef<HTMLButtonElement, ButtonProps> and exports a ButtonProps interface.
v4 (new-york-v4 registry style, apps/v4/registry/new-york-v4/ui/button.tsx)
outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50
Uses the v4 ring API : ring-[3px] (a 3px ring via the arbitrary-value syntax), no ring-offset, a border-ring border-colour-shift on focus, and ring-ring/50 (the ring colour at 50% opacity). The Button is now a plain function component (no React.forwardRef, React 19 forwards refs implicitly) typed as React.ComponentProps<"button">.
ALWAYS emit the variant that matches the consumer project's Tailwind version. To check : read package.json for tailwindcss (the major version), or read the consumer's globals.css for @import "tailwindcss" (v4) versus @tailwind base / components / utilities (v3). NEVER mix the two ring incantations in one file ; the v3 ring uses ring-offset, the v4 ring uses a border shift, and they layer poorly when combined.
Section : Icon button + Loading button patterns
Icon-only button (lucide-react icon)
import { Plus } from "lucide-react"
<Button size="icon" aria-label="Add item">
<Plus />
</Button>
The cva base layer includes [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4, so a lucide icon auto-sizes to 16px (size-4) without any explicit size prop. ALWAYS provide aria-label when the visible text is empty ; screen readers have no label otherwise.
Loading button (spinner + disabled state)
import { Loader2 } from "lucide-react"
function SubmitButton({ isPending }: { isPending: boolean }) {
return (
<Button disabled={isPending}>
{isPending && <Loader2 className="animate-spin" />}
{isPending ? "Saving..." : "Save"}
</Button>
)
}
The cva base layer also includes disabled:pointer-events-none disabled:opacity-50, so the visual disabled state is automatic. NEVER add a manual cursor-not-allowed class on top : the existing disabled:pointer-events-none removes hover and pointer events entirely, and Tailwind v4 sets cursor: default on buttons by default anyway (see shadcn-errors-tailwind-v3-v4-migration for cursor-pointer-on-button workarounds).
Section : RSC compatibility
Button is RSC-safe in both v3 and v4 form. The component file does NOT carry a "use client" directive, and it has no client-only React features (no useState, no useEffect, no DOM refs that the consumer's caller would need to use during render). ALWAYS import Button directly into a Server Component without wrapping it in a "use client" boundary. The button receives event handlers as props from the calling component (so the CALLER may need "use client" if the handler closes over client state), but Button itself does not.
NEVER add "use client" to components/ui/button.tsx. Doing so unnecessarily marks every importing module as a client module and breaks the RSC bundle split documented in shadcn-impl-rsc-vs-client-boundaries.
Anti-patterns (summary ; full catalogue in references/anti-patterns.md)
NEVER do these :
- NEVER pass multiple children to
<Button asChild> : Slot.Root accepts exactly one child and throws "React.Children.only expected to receive a single React element child" otherwise. Move composition INSIDE the child.
- NEVER use
asChild with a child that ignores forwarded refs and props (raw <input type="button">, an unwrapped function component without forwardRef on v3, or a component that forgets to spread ...props). The styling silently fails to attach.
- NEVER drop an icon into
size="default" and expect it to look right. ALWAYS use size="icon" for icon-only buttons.
- NEVER override
className by string concatenation : the cva machinery routes through cn(buttonVariants({ variant, size, className })), which uses twMerge to resolve conflicts. Hand-concatenating defeats conflict resolution and your overrides lose silently.
- NEVER replace
<Button asChild><Link href="/x"/></Button> with <Button onClick={() => router.push("/x")}>. You lose Next.js link prefetch, middle-click open-in-new-tab, keyboard activation semantics, and screen-reader role.
- NEVER toggle both
disabled and a manual hover variant class on the same render : the cva base layer already disables pointer events when disabled is true. Manual overrides collide with the base disabled state.
See references/anti-patterns.md for the full WHY and FIX of each.
Companion Skills
- shadcn-syntax-variant-cva : the cva variant API, defaultVariants, compoundVariants, VariantProps in depth. Read BEFORE extending buttonVariants with a custom variant.
- shadcn-core-architecture : the copy-not-install paradigm and the "you own the source" doctrine that lets you extend
button.tsx freely.
- shadcn-core-stack : Radix Slot, cva, tailwind-merge, clsx composition map ; explains exactly what Slot.Root does under the hood.
- shadcn-errors-radix-controlled : controlled-state failures across Radix primitives ; includes the
asChild multi-children crash and the non-forwardRef-child swallowed-props pattern.
- shadcn-impl-rsc-vs-client-boundaries : why Button stays server-safe and what triggers the
"use client" requirement elsewhere.
- shadcn-errors-tailwind-v3-v4-migration : the ring-2 -> ring-[3px] shift and the cursor-default-on-buttons gotcha.
Reference Links
- references/methods.md : Button component signature, buttonVariants cva instance, VariantProps typing, full variant + size enumeration verbatim.
- references/examples.md : ten canonical recipes including Next.js Link, react-router Link, icon-only, loading, custom warning variant, v3 vs v4 ring annotations.
- references/anti-patterns.md : six anti-patterns with WHY and FIX.
Sources
All claims in this skill trace to URLs in SOURCES.md :
Verified 2026-05-19.