# Button

Six variants with spring press feedback. The loading state swaps the label for a spinner without changing width.

**Category:** Inputs  
**Exports:** `Button`  
**npm dependencies:** motion (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/button.json
```
The file lands at `components/ui/button.tsx`; import from `@/components/ui/button`.

## API — Variants
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `variant` | `"primary" \| "secondary" \| "outline" \| "ghost" \| "destructive" \| "ember"` | `"primary"` |  |
| `size` | `"sm" \| "md" \| "lg" \| "icon"` | `"md"` |  |
| `loading` | `boolean` | `false` | Locks width, shows a spinner, sets aria-busy. |
| `...props` | `HTMLMotionProps<"button">` | — |  |

## Example — Variants

```tsx
import { ArrowRight, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";

export default function ButtonVariantsDemo({ size = "md", disabled = false }: { size?: "sm" | "md" | "lg"; disabled?: boolean }) {
  return (
    <div className="flex max-w-lg flex-wrap items-center justify-center gap-3">
      <Button variant="ember" size={size} disabled={disabled}>
        Get started <ArrowRight />
      </Button>
      <Button size={size} disabled={disabled}>
        Save changes
      </Button>
      <Button variant="secondary" size={size} disabled={disabled}>
        <Plus /> New project
      </Button>
      <Button variant="outline" size={size} disabled={disabled}>
        Export
      </Button>
      <Button variant="ghost" size={size} disabled={disabled}>
        Cancel
      </Button>
      <Button variant="destructive" size={size} disabled={disabled}>
        <Trash2 /> Delete
      </Button>
      <Button size="icon" variant="outline" disabled={disabled} aria-label="Add item">
        <Plus />
      </Button>
    </div>
  );
}
```

More examples: “Loading” — call get_component_examples.

## Source — components/ui/button.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps } from "motion/react";
import { cn } from "@/lib/utils";

const variants = {
  primary: "bg-primary text-primary-foreground hover:bg-primary/90",
  secondary: "bg-secondary text-secondary-foreground hover:bg-accent",
  outline: "border border-input bg-transparent text-foreground hover:bg-accent",
  ghost: "bg-transparent text-foreground hover:bg-accent",
  destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
  ember:
    "bg-ember text-ember-foreground shadow-[0_10px_30px_-10px_var(--color-ember),inset_0_1px_0_rgb(255_255_255/0.25)] hover:brightness-110",
} as const;

const sizes = {
  sm: "h-8 gap-1.5 rounded-md px-3 text-[13px] [&_svg]:size-3.5",
  md: "h-10 gap-2 rounded-lg px-4 text-sm [&_svg]:size-4",
  lg: "h-12 gap-2 rounded-xl px-5 text-[15px] [&_svg]:size-[18px]",
  icon: "size-10 rounded-lg [&_svg]:size-[18px]",
} as const;

export type ButtonProps = Omit<HTMLMotionProps<"button">, "children"> & {
  variant?: keyof typeof variants;
  size?: keyof typeof sizes;
  /** Swaps the label for a spinner while keeping the button's width. */
  loading?: boolean;
  children?: React.ReactNode;
};

function Spinner() {
  return (
    <svg viewBox="0 0 24 24" fill="none" className="animate-spin" aria-hidden>
      <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2.5" />
      <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
    </svg>
  );
}

export function Button({
  variant = "primary",
  size = "md",
  loading = false,
  disabled,
  className,
  children,
  type = "button",
  ...props
}: ButtonProps) {
  const reduce = useReducedMotion();
  const inert = disabled || loading;

  return (
    <motion.button
      type={type}
      whileTap={reduce || inert ? undefined : { scale: 0.97 }}
      transition={{ type: "spring", stiffness: 700, damping: 30 }}
      disabled={inert}
      aria-busy={loading || undefined}
      className={cn(
        "relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center whitespace-nowrap font-medium outline-none",
        "transition-[background-color,color,box-shadow,filter,opacity] duration-150",
        "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
        "disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:shrink-0",
        loading && "disabled:opacity-100",
        variants[variant],
        sizes[size],
        className,
      )}
      {...props}
    >
      <motion.span
        className="inline-flex items-center gap-[inherit]"
        initial={false}
        animate={
          loading
            ? { opacity: 0, y: reduce ? 0 : -8, filter: "blur(2px)" }
            : { opacity: 1, y: 0, filter: "blur(0px)" }
        }
        transition={{ type: "spring", stiffness: 500, damping: 32 }}
      >
        {children}
      </motion.span>
      <AnimatePresence>
        {loading && (
          <motion.span
            className="absolute inset-0 flex items-center justify-center"
            initial={{ opacity: 0, y: reduce ? 0 : 8 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: reduce ? 0 : 8 }}
            transition={{ type: "spring", stiffness: 500, damping: 32 }}
          >
            <Spinner />
          </motion.span>
        )}
      </AnimatePresence>
    </motion.button>
  );
}
```