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
Sizes
With Icon
Pass an element. A bare component reference (iconStart={Mail}) also works, but only from a Client Component.
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
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "default" | "soft" | "outline" | "ghost" | "text" | "destructive" | "subtle" | "ghost-destructive" | "link" | "default" | Visual style of the button. soft is the accent-tinted secondary that pairs with default; subtle is its neutral equivalent for toolbars. |
| 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. |
| iconStart | LucideIcon | 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 | LucideIcon | 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. |
| textHover | "accent" | "destructive" | "foreground" | "muted" | "accent" | Hover text color. Applies to the text variant only. |
| layoutAnimation | boolean | true | 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 type { LucideIcon } from "lucide-react"
import { Loader2 } from "lucide-react"
import { motion, useReducedMotion } from "motion/react"
import { cn } from "@/registry/lib/utils"
// 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
const textHoverClasses = {
accent: "hover:text-accent",
destructive: "hover:text-destructive",
foreground: "hover:text-foreground",
muted: "hover:text-muted-foreground",
} as const
/** An icon prop takes an ELEMENT (`iconEnd={<ArrowRight />}`), matching every
* other icon prop in the kit. A bare component (`iconEnd={ArrowRight}`) still
* works and is the original API, but prefer the element: a component is a
* function, functions do not cross the Server/Client boundary, and passing one
* from an App Router page fails the production build with "Functions cannot be
* passed directly to Client Components". An element is already rendered. */
type ButtonIcon = LucideIcon | React.ReactNode
/** Paint an icon prop in either form, tagging it with its position and hiding it
* behind the spinner while loading. */
function renderIcon(
icon: ButtonIcon | undefined,
position: "start" | "end",
loading: boolean
) {
if (!icon) return null
const hidden = loading ? "invisible" : undefined
// An ELEMENT first: it is also an object carrying `$$typeof`, so the component
// test below would swallow it.
if (React.isValidElement(icon)) {
const el = icon as React.ReactElement<{ className?: string }>
return React.cloneElement(el, {
"data-position": position,
className: cn(el.props.className, hidden),
} as React.Attributes & { className?: string })
}
// A COMPONENT TYPE. Test for the object form too, NOT just `typeof ===
// "function"`: a Lucide icon is built with `forwardRef`, which returns an
// object ({$$typeof, render, displayName}), so the function test misses every
// one of them and the raw object reaches React as a child ("Objects are not
// valid as a React child").
if (
typeof icon === "function" ||
(typeof icon === "object" && icon !== null && "$$typeof" in icon)
) {
const Icon = icon as LucideIcon
return <Icon data-position={position} className={hidden} />
}
return icon
}
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
textHover?: keyof typeof textHoverClasses
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
}
function Button({
className,
variant,
size,
iconSize,
textHover,
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);
};
// Reduce padding on icon side for visual balance. Skip the icon-only sizes
// (`icon`, `icon-sm`, `icon-lg`): they have no label, so adding side padding
// to their fixed square box shoves the glyph off-center inside the hover
// highlight.
const iconPaddingClasses =
size !== "icon" && size !== "icon-sm" && size !== "icon-lg" && size !== "inline"
? cn(iconStart && "pl-3", iconEnd && "pr-3")
: undefined
const buttonClasses = cn(
buttonVariants({ variant, size, className }),
"group/button",
iconSize && iconSizeClasses[iconSize],
iconPaddingClasses,
variant === "text" && textHoverClasses[textHover ?? "accent"],
loading && "pointer-events-auto cursor-wait"
)
const resolvedVariant = variant ?? "default"
// 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-3xs 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",
// 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. Both are accent-500 in
// either theme: a chip a step off its own label reads as two different
// blues, and the counter must never be the dimmer of the two.
(resolvedVariant === "soft" ||
resolvedVariant === "outline" ||
resolvedVariant === "ghost") && "bg-accent text-accent-foreground",
// 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
)
// Loading styles per variant - inline styles for guaranteed specificity
const getLoadingStyles = (): React.CSSProperties | undefined => {
if (!loading) return undefined;
switch (resolvedVariant) {
case "default":
// Lock in hover state: transparent bg, accent border & text
return {
backgroundColor: "transparent",
borderColor: "var(--color-accent)",
color: "var(--color-accent)",
};
case "outline":
// Lock in hover state: accent bg, light text
return {
backgroundColor: "var(--color-accent)",
color: "var(--color-accent-foreground)",
};
case "ghost":
// Lock in hover state: subtle accent bg
return {
backgroundColor: "color-mix(in srgb, var(--color-accent) 20%, transparent)",
};
case "destructive":
// Lock in hover state: solid destructive surface
return {
backgroundColor: "var(--color-destructive)",
color: "var(--color-destructive-foreground)",
};
default:
return undefined;
}
};
// 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]">
<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]">
<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}
style={getLoadingStyles()}
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 "@/registry/ui/button-variants"
// instead: this module is a client module, so the re-export is a client
// reference and calling it on the server throws.
export { Button, buttonVariants }
export type { ButtonProps, ButtonIcon }// NO "use client" HERE, 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.
import { cva, type VariantProps } from "class-variance-authority"
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-none disabled:opacity-50 disabled:cursor-not-allowed outline-none focus-visible:ring-ring-focus focus-visible:ring-[3px]",
{
variants: {
variant: {
// Primary: accent background, one step deeper 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 has always shifted its fill this way.
default:
"bg-accent text-accent-foreground border border-transparent text-xs font-semibold hover:bg-accent-600 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 border border-accent text-xs font-semibold hover:bg-accent-100 dark:hover:bg-accent-900 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-900 text-accent border border-transparent text-xs font-semibold hover:bg-accent-200 dark:hover:bg-accent-800 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-900 hover:text-accent active:scale-[0.98]",
// Text: inline text link style (hover color controlled via textHover prop)
text: "bg-transparent text-subtle-foreground font-semibold text-xs transition-colors",
// 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]",
// 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]",
// Ghost destructive: transparent with destructive hover (for icon close buttons)
"ghost-destructive":
"bg-transparent text-foreground hover:bg-destructive-100 dark:hover:bg-destructive-900 hover:text-destructive active:scale-[0.98]",
// Link: inline text link for card headers and inline contexts
link: "bg-transparent text-muted-foreground text-sm normal-case font-normal tracking-normal hover:text-foreground transition-colors duration-150 p-0 h-auto 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`.
size: {
default:
"h-8 px-4 rounded-action [&_svg]:size-3 [&_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 [&_svg]:shrink-0 gap-1",
lg: "h-10 px-5 rounded-action-lg text-sm [&_svg]:size-3.5 [&_svg]:shrink-0 gap-1.5",
icon: "size-8 rounded-action [&_svg]:size-3",
"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 [&_svg]:size-3.5",
// 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 [&_svg]:shrink-0",
},
},
// `link` and `text` paint no fill at any state, so their 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.
compoundVariants: [
{
variant: ["link", "text"],
className: "rounded-sm",
},
],
defaultVariants: {
variant: "default",
size: "default",
},
}
)
type ButtonVariantProps = VariantProps<typeof buttonVariants>
export { buttonVariants }
export type { ButtonVariantProps }