# Shadcn Syntax Button

> 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"`

- Skill: `impertio-studio/shadcn-syntax-button` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add impertio-studio/shadcn-syntax-button`
- Raw SKILL.md: https://api.skillmd.com/api/skills/impertio-studio/shadcn-syntax-button/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- License: MIT
- Author: Impertio-Studio (https://skillmd.com/u/impertio-studio)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/impertio-studio/shadcn-syntax-button

---


# 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

```tsx
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

1. 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.)
2. ALWAYS pass exactly ONE child element when `asChild` is true. NEVER two siblings ; Slot.Root crashes.
3. 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.
4. 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 :

```tsx
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.

```tsx
// Next.js (App Router, v15+)
import Link from "next/link"

<Button asChild>
  <Link href="/login">Login</Link>
</Button>
```

```tsx
// react-router-dom (v6 or v7 / React Router v7 rebrand)
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 :

```tsx
<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)

```tsx
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)

```tsx
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 :

1. 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.
2. 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.
3. NEVER drop an icon into `size="default"` and expect it to look right. ALWAYS use `size="icon"` for icon-only buttons.
4. 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.
5. 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.
6. 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](../shadcn-syntax-variant-cva/SKILL.md) : the cva variant API, defaultVariants, compoundVariants, VariantProps in depth. Read BEFORE extending buttonVariants with a custom variant.
- [shadcn-core-architecture](../../shadcn-core/shadcn-core-architecture/SKILL.md) : the copy-not-install paradigm and the "you own the source" doctrine that lets you extend `button.tsx` freely.
- [shadcn-core-stack](../../shadcn-core/shadcn-core-stack/SKILL.md) : Radix Slot, cva, tailwind-merge, clsx composition map ; explains exactly what Slot.Root does under the hood.
- [shadcn-errors-radix-controlled](../../shadcn-errors/shadcn-errors-radix-controlled/SKILL.md) : 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](../../shadcn-impl/shadcn-impl-rsc-vs-client-boundaries/SKILL.md) : why Button stays server-safe and what triggers the `"use client"` requirement elsewhere.
- [shadcn-errors-tailwind-v3-v4-migration](../../shadcn-errors/shadcn-errors-tailwind-v3-v4-migration/SKILL.md) : the ring-2 -> ring-[3px] shift and the cursor-default-on-buttons gotcha.

## Reference Links

- [references/methods.md](references/methods.md) : Button component signature, buttonVariants cva instance, VariantProps typing, full variant + size enumeration verbatim.
- [references/examples.md](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](references/anti-patterns.md) : six anti-patterns with WHY and FIX.

## Sources

All claims in this skill trace to URLs in `SOURCES.md` :

- https://ui.shadcn.com/docs/components/radix/button (canonical Button docs page, variant + size prop tables)
- https://github.com/shadcn-ui/ui (apps/v4/registry/new-york-v4/ui/button.tsx, verbatim v4 source ; apps/v4/public/r/styles/default/button.json, verbatim v3 source)
- https://www.radix-ui.com/primitives/docs/utilities/slot (Slot.Root API + Slottable for multi-child cases)
- https://cva.style/docs (cva function signature, defaultVariants, VariantProps helper)
- https://github.com/dcastil/tailwind-merge (twMerge conflict resolution that powers `cn()`)
- https://github.com/shadcn-ui/ui/issues/6843 (Tailwind v4 cursor-default on buttons, 126 reactions, open)

Verified 2026-05-19.

