Button
An interactive button with multiple variants, sizes, icons, loading state, and Motion layout animation.
Installation
Usage
import { Button } from "@/components/ui/button"Examples
Default
Variants
Seven fills. soft is the accent-tinted secondary that pairs with default, and subtle is its neutral version for toolbars. link paints no fill, so it ignores the size's height and sits on the text line.
Sizes
Four heights (24/28/32/40) plus three square icon sizes. Radius scales with height, so all four read as one shape, and an Input given the same size matches the button beside it exactly.
With Icon
Pass an element: it works anywhere, including from a Server Component. A bare component reference (iconStart={Mail}) also works, but only from a Client Component, because a function cannot cross the server/client boundary.
Icon sizes
The icon box scales with the button (12/14/14/16px across the four heights) and the icon-side padding is 2px tighter than the label side at every size, so leading and trailing icons both read balanced. The stroke follows the label weight too, matching semibold Geist.
With Badge
The counter holds one treatment through hover instead of restyling with the button, so the number never gets harder to read as the pointer lands. On filled buttons it is an opaque chip; on tinted ones it steps a shade deeper than the surface.
Loading
loading swaps the label for a spinner, blocks the click, and sets aria-busy. The box does not resize, so a row of buttons holds its layout. Each variant keeps its own hover fill while busy, so a loading button reads as the same button mid-press rather than turning into a different one.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "soft" | "outline" | "ghost" | "destructive" | "subtle" | "link" | "default" | Visual style of the button. soft is the accent-tinted secondary that pairs with default; subtle is its neutral equivalent for toolbars. link paints no fill, so it ignores the size's height and padding and sits on the text line at every size. |
| size | "default" | "xs" | "sm" | "lg" | "icon" | "icon-sm" | "icon-lg" | "inline" | "default" | Height and padding preset. icon (32px), icon-sm (24px), and icon-lg (40px) are square; inline flows with surrounding text. variant="link" takes only the type scale from this, never the height or padding. |
| iconStart | TablerIcon | ReactNode | - | Icon rendered before the label. Prefer an element (<Mail />); a bare component (Mail) still works but cannot be passed from a Server Component. |
| iconEnd | TablerIcon | ReactNode | - | Icon rendered after the label. Prefer an element (<ChevronRight />); a bare component still works but cannot be passed from a Server Component. |
| iconSize | "xs" | "sm" | "default" | "lg" | "xl" | - | Overrides the icon size implied by size. |
| badge | React.ReactNode | - | Count badge rendered after the label, tinted to match the variant. |
| badgeClassName | string | - | Extra classes for the badge element. |
| loading | boolean | false | Shows a centered spinner, locks in the variant's hover styling, and blocks clicks while preserving the button's width. |
| layoutAnimation | boolean | false | Motion layout animation on content changes. Set false to prevent glitches on rapid state changes; disabled automatically under reduced motion. |
| asChild | boolean | false | Renders the child element instead of a <button>, merging classes and injecting icons, badge, and spinner into it. |
"use client"
import * as React from "react"
import { IconLoader2 as Loader2 } from "@tabler/icons-react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/registry/lib/utils"
import { renderIconProp, type IconProp } from "@/registry/lib/icon-prop"
// The cva lives in its own module with NO "use client", so a Server Component
// can call `buttonVariants()` to paint a link as a button. Calling it through
// THIS module fails the production build: every export of a client module is a
// client reference, and a re-export is one too. See button-variants.ts.
import { buttonVariants, type ButtonVariantProps } from "@/registry/ui/button-variants"
const iconSizeClasses = {
xs: "[&_svg]:!size-3",
sm: "[&_svg]:!size-3.5",
default: "", // uses variant default
lg: "[&_svg]:!size-5",
xl: "[&_svg]:!size-6",
} as const
/** Button's icon props take an ELEMENT or a bare component. See IconProp for
* which to reach for and why the element form is the one that works from a
* Server Component. */
type ButtonIcon = IconProp
/** Paint an icon prop in either form, tagging it with its position and hiding it
* behind the spinner while loading. The two-form detection is shared with Input
* in `registry/lib/icon-prop`: it is the part that is easy to get wrong. */
function renderIcon(
icon: ButtonIcon | undefined,
position: "start" | "end",
loading: boolean
) {
return renderIconProp(icon, {
"data-position": position,
className: loading ? "invisible" : undefined,
})
}
type ButtonProps = Omit<React.ComponentProps<"button">, "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"> &
ButtonVariantProps & {
asChild?: boolean
iconStart?: ButtonIcon
iconEnd?: ButtonIcon
badge?: React.ReactNode
badgeClassName?: string
iconSize?: keyof typeof iconSizeClasses
loading?: boolean
/** Opt IN to Framer Motion layout animation (the button glides to its new
* position when surrounding layout changes). Off by default: with it on,
* ANY page reflow (a grid changing column count, a sibling unmounting, a
* filter re-render) sent every visible button sliding independently of
* its container, reading as a layout-shift glitch. */
layoutAnimation?: boolean
}
// @use-when any action the user can take. To render a LINK as a button from a
// Server Component, paint the anchor with `buttonVariants` from
// `button-variants`: `asChild` runs React.Children.only, which children
// handed across the RSC boundary do not survive, so it throws at request
// time. `asChild` is correct inside a client component.
function Button({
className,
variant,
size,
iconSize,
badge,
badgeClassName,
asChild = false,
iconStart,
iconEnd,
loading = false,
layoutAnimation = false,
disabled,
onClick,
children,
...props
}: ButtonProps) {
const shouldReduceMotion = useReducedMotion()
// When loading, allow pointer events (for hover states) but block clicks
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (loading) {
e.preventDefault();
return;
}
onClick?.(e);
};
// The icon side gets 2px LESS padding than the label side, and that reduction
// TRACKS THE SIZE RAMP. It used to be a flat `pl-3`/`pr-3` (12px) at every
// size, which balanced only at `sm`: against the ramp's own 8/12/16/20 label
// padding it was 4px too tight at `xs` (icon side wider than label side, the
// asymmetry reversed) and 8px too loose at `lg` (icon crammed to the edge, a
// visible right-heavy button). A flat value cannot balance a scaling one.
//
// Why 2px and not symmetric: icons vary wildly in how much of their 24-box
// they fill. A full-bleed glyph (mail, inbox) insets ~0.9u, a directional one
// (chevron 7.9u, arrow 3.9u) carries its own edge whitespace that padding
// cannot remove. Symmetric padding balances the full-bleed case dead-on but
// leaves a trailing chevron +4-5px right-heavy; a 2px reduction is the value
// that holds BOTH archetypes inside a +1.7 to +3.0px band across the whole
// ramp. Measured, not eyeballed: see the icon-fill table in the reference doc.
//
// Icon-only sizes (`icon`, `icon-sm`, `icon-lg`) and `inline` are skipped:
// no label means no side to balance against, and padding a fixed square box
// shoves the glyph off-centre inside the hover highlight.
const iconSidePadding: Record<string, { start: string; end: string }> = {
xs: { start: "pl-1.5", end: "pr-1.5" }, // label px-2 (8) -> 6
sm: { start: "pl-2.5", end: "pr-2.5" }, // label px-3 (12) -> 10
default: { start: "pl-3.5", end: "pr-3.5" }, // label px-4 (16) -> 14
lg: { start: "pl-4.5", end: "pr-4.5" }, // label px-5 (20) -> 18
}
const sizePad = iconSidePadding[size ?? "default"]
const iconPaddingClasses = sizePad
? cn(iconStart && sizePad.start, iconEnd && sizePad.end)
: undefined
const resolvedVariant = variant ?? "default"
// Loading holds the variant's OWN hover fill, so a busy button reads as the
// same button mid-press rather than as a different variant. `default` used to
// lock in a transparent accent-bordered box, which is `outline`'s REST style:
// the exact collision button-variants.ts calls out twice and avoids on both
// hover rules. `soft` and `subtle` had no loading state at all.
//
// CLASSES, NOT INLINE STYLES. These were inline accent-alias `var()` reads
// for specificity they never needed: a class appended after the variant's
// lands later in the same layer and wins on its own, and tailwind-merge drops
// the fill it replaces. Inline styles were also actively WRONG, because
// `--color-*` is an `@theme inline` alias declared once at `:root`. A custom
// property resolves where it is DECLARED, so the value froze against the root
// palette and never saw a scope below it: a d-a-r-k island (`SiteHeader`
// surface="hero" adds that class to a descendant) or a consumer scoping the
// kit under a class over their own root palette both got root colors.
// Utilities do not have this problem: `bg-accent` compiles to `var(--accent)`,
// which re-resolves at each element. Never read `var(--color-<semantic>)`
// from component source; the numbered scale is fine, declared not aliased.
// Going through `buttonClasses` also fixes `asChild`, which never received
// the inline styles at all and so had no loading state in any variant.
const loadingClasses = !loading
? undefined
: {
default: "bg-accent-600 dark:bg-accent-500 text-accent-foreground",
outline: "bg-accent-100 dark:bg-accent-800 text-accent dark:text-accent-400",
soft: "bg-accent-200 dark:bg-accent-800 text-accent dark:text-accent-400",
ghost: "bg-accent-100 dark:bg-accent-800 text-accent dark:text-accent-400",
destructive: "bg-destructive-600 text-destructive-foreground",
"ghost-destructive":
"bg-destructive-100 dark:bg-destructive-800 text-destructive dark:text-destructive-400",
subtle: "bg-neutral-500/15 text-foreground",
// `link` paints no fill in any state, so there is nothing to hold. The
// spinner inherits the text color and reads on its own.
link: undefined,
}[resolvedVariant]
const buttonClasses = cn(
buttonVariants({ variant, size, className }),
"group/button",
iconSize && iconSizeClasses[iconSize],
iconPaddingClasses,
loading && "pointer-events-auto cursor-wait",
// Last, so tailwind-merge drops the rest fill it replaces. It also outranks
// a caller's `className` fill, which is correct: loading is a state the
// component owns, and it lasts only as long as the flag.
loadingClasses
)
// The count is a DATUM, not a state. Every chip is OPAQUE and carries the
// strongest contrast available against its own button, which is why none of
// them carry a hover rule: an opaque chip cannot be swallowed when the
// button's fill shifts underneath it, so the number stays equally readable
// at the moment the pointer lands on it.
// `tabular-nums` keeps a changing count from resizing the button.
const badgeClasses = cn(
"inline-flex items-center justify-center min-w-4 h-4 rounded-full px-1 text-2xs font-medium tabular-nums transition-colors duration-150",
// Accent-FILLED buttons invert the chip to the foreground color, because
// accent-on-accent fails contrast.
resolvedVariant === "default" && "bg-accent-foreground text-accent",
resolvedVariant === "destructive" &&
"bg-destructive-foreground text-destructive",
// The transparent destructive button takes a solid destructive chip, the
// same relationship ghost has to its accent chip below.
resolvedVariant === "ghost-destructive" &&
"bg-destructive text-destructive-foreground dark:bg-destructive-400 dark:text-destructive-950",
// Accent-tinted and transparent buttons get a solid accent chip. This is
// the tabs.tsx active-tab counter exactly: that chip sits on the same
// accent-100/900 tint the `soft` button uses, and a pale chip on a pale
// surface reads as lost rather than as a count.
// The chip tracks the LABEL, one accent per control: a chip a step off its
// own label reads as two different blues, and the counter must never be the
// dimmer of the two. Light stays accent-500 for both. DARK moved to 400 for
// both when the tint went to accent-800, and the chip had to follow or it
// would have been the dimmer one. Its text inverts to accent-950 there,
// because accent-foreground on an accent-400 fill is only 3.09:1; on the
// accent-500 fill light mode still uses, the same near-white is 5.0:1.
// Change the label's accent step and you must change the chip's with it.
(resolvedVariant === "soft" ||
resolvedVariant === "outline" ||
resolvedVariant === "ghost") && "bg-accent text-accent-foreground dark:bg-accent-400 dark:text-accent-950",
// The neutral button keeps a neutral chip: a brand-colored count would
// inject accent into the one variant that deliberately has none. Sits
// between the button's own rest and hover washes so it reads on both.
// The alpha chip needs a SOLID step once the page goes near-black:
// neutral-500/20 over an already-dim button lands 1.26:1 against it, so the
// disc vanishes and only the digit floats, reading as broken next to five
// solid chips. A near-black surface has too little range left for the disc
// to be a strong shape AND carry a strong digit at once, so the digit wins:
// neutral-600 puts the disc at 2.38:1 with the number still at 6.42:1.
// (Bare "d-a-r-k" as a word is avoided here on purpose: verify-registry
// scans raw quoted spans, and an apostrophe in a nearby comment turns this
// block into a fake string literal, tripping its site-only-class check.)
resolvedVariant === "subtle" && "bg-neutral-500/20 dark:bg-neutral-600 text-foreground",
badgeClassName
)
// Content rendering - use contents class to "unwrap" span when not loading
const buttonContent = (
<>
{renderIcon(iconStart, "start", loading)}
<span className={cn("[color:inherit] [line-height:inherit] [letter-spacing:inherit]", loading ? "invisible" : "contents")}>{children}</span>
{badge !== undefined && badge !== null && (
<span className={cn(badgeClasses, loading && "invisible")}>
{badge}
</span>
)}
{renderIcon(iconEnd, "end", loading)}
{loading && (
<span className="absolute inset-0 flex items-center justify-center [color:inherit]">
{/* motion-exempt: an indeterminate spinner is the only "still working"
cue on a loading button, so it keeps spinning under reduced motion.
Stopping it reads as frozen rather than as calm. */}
<Loader2 className="!size-4 animate-spin" />
</span>
)}
</>
)
// When using asChild, clone the child element and inject icons into its children
if (asChild) {
const child = React.Children.only(children) as React.ReactElement<{
className?: string
children?: React.ReactNode
}>
return React.cloneElement(child, {
...props,
// `disabled` is inert on non-button children (e.g. an <a>/<Link>), so also
// block interaction the way that works on any element. aria-busy is only
// meaningful while loading.
disabled,
...(disabled ? { "aria-disabled": true, tabIndex: -1 } : {}),
...(loading ? { "aria-busy": true } : {}),
className: cn(buttonClasses, disabled && "pointer-events-none", child.props.className),
"data-slot": "button",
children: (
<>
{renderIcon(iconStart, "start", loading)}
<span className={cn("[color:inherit] [line-height:inherit] [letter-spacing:inherit]", loading ? "invisible" : "contents")}>{child.props.children}</span>
{badge !== undefined && badge !== null && (
<span className={cn(badgeClasses, loading && "invisible")}>
{badge}
</span>
)}
{renderIcon(iconEnd, "end", loading)}
{loading && (
<span className="absolute inset-0 flex items-center justify-center [color:inherit]">
{/* motion-exempt: same indeterminate spinner as above. */}
<Loader2 className="!size-4 animate-spin" />
</span>
)}
</>
),
} as React.Attributes & { className: string; children: React.ReactNode; "data-slot": string; disabled?: boolean; "aria-busy"?: boolean })
}
return (
<motion.button
data-slot="button"
className={buttonClasses}
disabled={disabled}
aria-busy={loading}
onClick={handleClick}
layout={layoutAnimation && !shouldReduceMotion ? "position" : false}
transition={{ duration: shouldReduceMotion ? 0 : 0.15, ease: "easeOut" }}
{...props}
>
{buttonContent}
</motion.button>
)
}
// `buttonVariants` is re-exported for the client components that already import
// it from here. In a SERVER component import it from the `button-variants`
// sibling module instead: this module is a client module, so the re-export is a
// client reference and calling it on the server throws. (The sibling is named
// rather than given as a path, because the path differs upstream and in an
// installed project, and only the import statements get rewritten on install.)
// rsc-reexport: buttonVariants -> button-variants
// kept for existing client call sites; server callers import the sibling
export { Button, buttonVariants }
export type { ButtonProps, ButtonIcon }import { cva, type VariantProps } from "class-variance-authority"
// ICON STROKE IS A FUNCTION OF THE LABEL'S FONT WEIGHT, not of the component.
//
// A Tabler glyph is stroked in a 24-unit viewBox, so the ink it actually paints
// is `strokeWidth * iconSize / 24` px. Type ink is measurable the same way:
// Geist's stem is 0.084em at weight 400, 0.106 at 500, 0.128 at 600. Set the two
// equal and Tabler's default stroke of 2 turns out to BE Geist Regular
// (24 * 0.084 = 2.02) when the icon box matches the font size. That is the
// calibration point the whole ramp hangs off, and it is why nothing here needed
// a stroke until now: at regular weight the default was already right.
//
// Button labels are `font-semibold`, and 600 wants 2.7-3.1. Every button in the
// kit was therefore pairing Semibold text with Regular-weight icons, two weight
// steps apart, which is the "icons look wrong" report this came from.
//
// The ceiling is set by counters, not by the stroke. Rasterising the shipped
// icon set, `Trash2`'s lid counter is the tightest thing in it and clears
// exactly `(3.94 - strokeWidth) * iconSize / 24` px. At the old 12px box that
// left 0.97px at stroke 2, i.e. no headroom at all: raising stroke alone just
// fills the counter in. Growing the box buys the headroom back, so the two moves
// have to happen together. 14px at 2.25 lands 89% of the way to the Semibold
// target and OPENS the tightest counter slightly (0.99px), so nothing in the set
// clogs relative to what already shipped.
//
// The stroke is a token rather than a literal because it is a property of the
// TYPEFACE. Swap Geist for something with a different stem and this is the knob
// that re-matches the icons; `--stroke-icon` (regular-weight contexts) and
// `--stroke-icon-action` (a semibold control label) both ship from `base`.
const buttonVariants = cva(
// Base styles matching your design system
"relative inline-flex items-center justify-center shrink-0 select-none cursor-pointer transition-[transform,color,background-color,border-color] duration-150 motion-reduce:transition-[color,background-color,border-color] disabled:opacity-50 disabled:cursor-not-allowed outline-none focus-visible:ring-ring-focus focus-visible:ring-[3px] [&_svg]:[stroke-width:var(--stroke-icon-action)]",
{
variants: {
variant: {
// Primary: accent background, one step further from the page on hover.
// It does NOT invert to a bordered/transparent state: that was
// `outline`'s exact rest style, so hovering either variant made it read
// as the other. Mirrors `destructive`, which shifts its fill this way.
//
// DARK RESTS A STEP DOWN (accent-600), and its hover goes UP, not down.
// Both halves are deliberate. A saturated accent-500 plate on a
// near-black page is the loudest thing on it; 600 quiets the fill
// against the page from 3.94:1 to 2.78:1 while RAISING the label on it
// from 5.0:1 to 7.07:1, so it reads calmer and more legibly at once.
// The hover then has to reverse: "one stop deeper" means more presence
// only when the page is light. Darkening on a dark page walks the fill
// TOWARD the background, so the old dark hover (500 to 600) made the
// button recede under the pointer. Dark hover goes 600 to 500 instead,
// which is the same one-step fill shift pointed the way that gains
// contrast. Light is untouched: 500 rests, 600 hovers.
// Change either step and re-check the loading fill in button.tsx, which
// has to stay off both of them or a busy button looks like a resting one.
default:
"bg-accent dark:bg-accent-600 text-accent-foreground border border-transparent text-xs font-semibold hover:bg-accent-600 dark:hover:bg-accent-500 active:scale-[0.98]",
// Outline: accent border, soft accent tint on hover. It does NOT fill
// solid accent: that is `default`'s rest style, so a hovered outline
// read as a primary button. Border and text hold steady and only the
// fill arrives, so the button keeps its identity through the state
// change. Same tint the `ghost` hover and `soft` rest use.
outline:
"bg-transparent text-accent dark:hover:text-accent-400 border border-accent text-xs font-semibold hover:bg-accent-100 dark:hover:bg-accent-800 active:scale-[0.98]",
// Soft: accent-tinted fill, i.e. the `ghost` hover state made permanent.
// The secondary that pairs with `default`: it carries the brand tint so
// the two read as one family, but it has no border and no saturated
// fill, so it never competes with the primary or mimics its shape.
// Same tint pair as the kit's selected state (accent-100/accent-900).
soft:
"bg-accent-100 dark:bg-accent-800 text-accent dark:text-accent-400 border border-transparent text-xs font-semibold hover:bg-accent-200 dark:hover:bg-accent-700 active:scale-[0.98]",
// Ghost: transparent with accent background on hover
ghost:
"bg-transparent text-foreground text-xs font-semibold hover:bg-accent-100 dark:hover:bg-accent-800 hover:text-accent dark:hover:text-accent-400 active:scale-[0.98]",
// Destructive: subtle destructive bg, outline-like on hover
destructive:
"bg-destructive text-destructive-foreground border border-transparent text-xs font-semibold hover:bg-destructive-600 active:scale-[0.98]",
// Ghost-destructive: `ghost`'s shape in the destructive hue, for the
// quiet delete in a dialog footer or menu. It exists because the
// alternative was recoloring `ghost`'s label at the call site, which
// paints ghost's ACCENT hover fill under a destructive label: two
// semantic hues on one control at the exact moment the pointer lands
// on it. Hue is the variant's to own, so fill and label move together
// here the same way they do on every other variant.
"ghost-destructive":
"bg-transparent text-destructive dark:text-destructive-400 text-xs font-semibold hover:bg-destructive-100 dark:hover:bg-destructive-800 active:scale-[0.98]",
// Subtle: tinted neutral surface for toolbar buttons
subtle:
"bg-neutral-500/10 text-foreground border border-transparent text-xs font-semibold hover:bg-neutral-500/15 active:scale-[0.98]",
// Link: inline text link for card headers and inline contexts
// Its BOX lives in the compound rule below, not here: a variant string
// is emitted before the size variant, so `h-auto p-0` written here is
// silently overwritten by whatever height and padding the size carries.
link: "bg-transparent text-muted-foreground text-sm normal-case font-normal tracking-normal hover:text-foreground transition-colors duration-150 cursor-pointer",
},
// Radius lives HERE, not on the variants, because it is a function of
// height, and it reads from the ACTION ramp rather than the shared radius
// ladder. Roundness is only ever seen as a fraction of the box, so four
// heights wearing one corner value are four different shapes: 12px is 3/8
// of the 32px default and exactly half of the 24px xs, and half IS a pill.
// Snapping the short sizes to the nearest ladder rung trades that for a
// default that reads rounder than every button beside it. Each
// `rounded-action-*` rung is 3/8 of its own height instead, so all four
// sizes read as one shape. Change a height, re-derive its radius.
// Side padding steps a constant 4px across the ramp (8/12/16/20). It
// used to end on 24, which made the last step twice every other one and
// left `lg` looking loose next to `default`.
//
// TOUCH TARGETS. The four full-size rungs grow their BOX to 44px on a
// coarse pointer (WCAG 2.5.5), and carry the matching 44px corner with
// them: a height change owes a radius change in the same edit, so the
// touch rung reads as the same shape the mouse rung does. Growing the box
// is the right mechanism here rather than `.tap-target`, whose inset
// overlay is documented as unsafe next to another interactive control,
// and a Button sits next to another Button constantly (dialog footers,
// toolbars, table row actions). `min-h`/`min-w` rather than a fixed 44,
// so a long label or a tall wrapped one still grows past it.
//
// The three SHORT rungs (`xs`, `sm`, `icon-sm`) deliberately do not grow.
// A caller reaches for them because the row is dense, so tripling a 24px
// control's area would reflow exactly the layouts that chose it and would
// be fought back with `className` at every call site, which is worse than
// no rule at all. They stay at or above the 24px floor of WCAG 2.5.8, and
// the components that ship them in a real touch row already opt in by
// hand: Kebab grows its 28px square to 44 on coarse. A dense row that
// wants a thumb target asks for one.
//
// ICON BOX steps 12 / 14 / 14 / 16 against labels of 11 / 12 / 13 / 14px.
// It is not a straight `iconSize = fontSize`, because the stroke set on
// the base string above is one value and the ink it paints scales with the
// BOX, so the box is what tunes each rung onto its label: at 2.25 the three
// rungs paint 1.13 / 1.31 / 1.50px against Semibold targets of 1.24 / 1.36
// / 1.47 / 1.58, i.e. 90 / 97 / 89 / 95%. `xs` and `icon-sm` hold at 12px
// even though 14 would score better, because the box is 24px: a 14px glyph
// fills more than half of it and the size exists for dense rows. The
// icon-only rungs match the labelled rung of their own HEIGHT (icon-sm to
// xs, icon to default, icon-lg to lg) so a toolbar mixing the two reads as
// one row rather than two glyph sizes.
size: {
default:
"h-8 px-4 rounded-action pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:rounded-action-xl [&_svg]:size-3.5 [&_svg]:shrink-0 gap-1.5",
xs: "h-6 px-2 rounded-action-xs text-2xs [&_svg]:size-3 [&_svg]:shrink-0 gap-1",
sm: "h-7 px-3 rounded-action-sm text-1xs [&_svg]:size-3.5 [&_svg]:shrink-0 gap-1",
lg: "h-10 px-5 rounded-action-lg pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:rounded-action-xl text-sm [&_svg]:size-4 [&_svg]:shrink-0 gap-1.5",
icon: "size-8 rounded-action pointer-coarse:size-11 pointer-coarse:rounded-action-xl [&_svg]:size-3.5",
"icon-sm": "size-6 rounded-action-xs [&_svg]:size-3",
// Square counterpart to `lg`, so a 40px control row (lg Button, lg Nav,
// lg Tabs) can carry an icon-only action without breaking its height.
// Glyph matches `lg`'s size-3.5 rather than `icon`'s size-3.
"icon-lg": "size-10 rounded-action-lg pointer-coarse:size-11 pointer-coarse:rounded-action-xl [&_svg]:size-4",
// Inline: for text buttons that flow with content. Its box is the text
// line (~20px), so it takes the smallest rung: a `ghost` inline button
// still paints a hover fill, and that fill needs a corner.
inline:
"h-auto px-0 rounded-sm gap-1 [&_svg]:size-3.5 [&_svg]:shrink-0",
},
},
// `link` paints no fill at any state, so its box is the text line, not the
// size variant's height. Taking that height's corner would wrap a ~20px run
// of text in a 12px focus ring. Compound rules are emitted after the size
// variant, so this wins on merge whatever size is passed.
// The HEIGHT and PADDING belong here for the same reason the radius does,
// and they used to sit in the `link` variant string, where they never once
// applied: a bare `variant="link"` shipped a 32px box with 16px of side
// padding, and only `size="inline"` undid it. Nobody reaching for `link`
// wants a 32px box, so the variant no longer asks for a size to behave.
// It opts OUT of the touch growth for the same reason: a link runs inside a
// sentence, and a sentence whose link is 44px tall has a 44px line box, so
// the paragraph around it reflows on every phone. The text line is the
// target for a link, and prose is not a control row.
compoundVariants: [
{
variant: ["link"],
className:
// Stroke drops back to the regular-weight token for the same reason
// the box does: `link` is the one variant that sets `font-normal`, so
// the action stroke would make its glyph heavier than the word it sits
// in. At 14px text and a 14px box, plain 2 is already a 99% match.
"h-auto p-0 rounded-sm pointer-coarse:rounded-sm pointer-coarse:min-h-0 pointer-coarse:min-w-0 [&_svg]:[stroke-width:var(--stroke-icon)]",
},
],
defaultVariants: {
variant: "default",
size: "default",
},
}
)
type ButtonVariantProps = VariantProps<typeof buttonVariants>
// NO "use client" IN THIS FILE, ON PURPOSE..
//
// `button.tsx` is a client module (it uses Motion and `useReducedMotion`), and
// under React Server Components EVERY export of a client module becomes a client
// reference: it can be rendered, or passed as a prop, but never CALLED on the
// server. So the standard idiom for painting a link as a button
//
// <Link className={buttonVariants({ variant: "ghost" })} />
//
// failed the production build from any App Router page, which is a Server
// Component by default:
//
// Attempted to call buttonVariants() from the server but buttonVariants is on
// the client.
//
// Re-exporting it from `button.tsx` does NOT fix this, because the re-export is
// itself a client reference. The variants have to live in their own module with
// no directive, which both graphs can then import. Import from HERE in a Server
// Component; `button.tsx` re-exports it for the client callers that already do.
//
// This warning sits at the BOTTOM of the file, which looks odd and is
// deliberate. The shadcn CLI reprints every file it installs and loses the
// comments around the import block, so the same words written at the top of
// the file reached nobody they were written for, on every pull. Down here they
// survive. `verify:registry` fails the build if a comment leads this file.
export { buttonVariants }
export type { ButtonVariantProps }