# Button

> Pill button in twelve styles and five sizes with a spring press, leading and trailing icons, inline links whose underline draws in, a Spinner loading state, an async state machine and a magnetic hover.

- Collection: Components
- Tier: free
- Docs: https://geturui.com/docs/button
- Install: `npx shadcn@latest add @geturui/button`
- Dependencies: motion, lucide-react

## Usage

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

export function Actions() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button trailing="arrow">Get started</Button>
      <Button variant="secondary" iconLeading={Plus}>New project</Button>
      <Button variant="destructive" iconLeading={Trash2}>Delete project</Button>
      <Button variant="link" href="/changelog">See what shipped</Button>
      <StatefulButton
        variant="secondary"
        successLabel="Saved"
        onAction={() => fetch("/api/save", { method: "POST" })}
      >
        Save changes
      </StatefulButton>
      <MagneticButton variant="inverse">Hover me</MagneticButton>
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| variant | `"primary" | "secondary" | "outline" | "ghost" | "inverse" | "pro" | "danger" | "destructive" | "destructive-ghost" | "link" | "link-muted" | "link-destructive"` | "primary" | Visual style. danger is the outlined destructive, destructive the solid one, destructive-ghost the quiet one; link variants drop the pill and draw an underline on hover. |
| size | `"xs" | "sm" | "md" | "lg" | "xl"` | "md" | Height and padding: 28, 32, 40, 48 or 56px. Link variants keep only the type size. |
| iconLeading | `ComponentType | ReactNode` | — | Icon before the label: a component (Plus) or a node (<Plus />). Sized to the button. |
| iconTrailing | `ComponentType | ReactNode` | — | Icon after the label; nudges right on hover. |
| trailing | `"arrow" | "external" | ReactNode` | — | Glyph after the label. The built-in arrows nudge on hover. |
| href | `string` | — | Renders an <a> with the same look (or use ButtonLink). Disabled links drop the href. |
| loading | `boolean` | false | Shows a Spinner, keeps the width and blocks presses while keeping focus on the button. |
| showTextWhileLoading | `boolean` | false | Keep the label visible with the spinner in place of the leading icon. |
| iconOnly | `boolean` | false | Square button sized for a single icon. Pass aria-label. |
| block | `boolean` | false | Stretch to the container width. |
| onAction | `() => Promise<unknown>` | — | StatefulButton only. Resolve to show the check, reject to shake. |
| successLabel | `ReactNode` | "Done" | StatefulButton only. Label shown for 1.8s after the action resolves. |
| strength | `number` | 0.35 | MagneticButton only. How far the button follows the cursor (0–1). |
| buttonClasses() | `({ variant, size, iconOnly, block, className }) => string` | — | Server-safe class string for anything that should look like a button. |
| ...props | `HTMLMotionProps<"button">` | — | Any button or motion prop, forwarded to the element. |

## Accessibility

- Renders a native <button> with type="button" by default, so Enter and Space work and it never submits forms by accident; with href it renders a real <a>.
- loading sets aria-busy and aria-disabled instead of disabled, so keyboard focus is not lost; presses and implicit form submits are blocked until it finishes.
- Disabled links render without href and with aria-disabled, so they are skipped by Tab and can't be followed.
- Icons and the spinner are aria-hidden; icon-only buttons need an aria-label. Visible focus ring on keyboard focus.

## Source

### components/ui/button.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring, type HTMLMotionProps } from "motion/react";
import { ArrowRight, ArrowUpRight, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { Spinner, type SpinnerSize } from "@/components/ui/spinner";

export { buttonClasses, isLinkVariant, type ButtonVariant, type ButtonSize } from "./button-classes";
import { buttonClasses, isLinkVariant, type ButtonVariant, type ButtonSize } from "./button-classes";

export type ButtonIconComponent = React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>;
/** An icon component (e.g. a lucide icon) or an already-rendered node. */
export type ButtonIcon = ButtonIconComponent | React.ReactNode;

interface ButtonVisualProps {
  variant?: ButtonVariant;
  size?: ButtonSize;
  /** Trailing glyph that nudges on hover. */
  trailing?: "arrow" | "external" | React.ReactNode;
  /** Icon before the label. Pass a component (`Plus`) or a node (`<Plus />`). */
  iconLeading?: ButtonIcon;
  /** Icon after the label. Pass a component or a node. */
  iconTrailing?: ButtonIcon;
  /** Shows a spinner, keeps the width and blocks presses (focus stays on the button). */
  loading?: boolean;
  /** Keep the label visible beside the spinner while loading. */
  showTextWhileLoading?: boolean;
  iconOnly?: boolean;
  block?: boolean;
  children?: React.ReactNode;
}

export interface ButtonProps extends Omit<HTMLMotionProps<"button">, "children">, ButtonVisualProps {
  /** Renders an <a> with the same look. The forwarded ref then points at the anchor. */
  href?: string;
  target?: string;
  rel?: string;
  download?: boolean | string;
}

export interface ButtonLinkProps extends Omit<HTMLMotionProps<"a">, "children">, ButtonVisualProps {
  href?: string;
  /** Renders without href and with aria-disabled, so it is skipped by Tab and can't be followed. */
  disabled?: boolean;
}

const iconClass: Record<ButtonSize, string> = { xs: "size-3.5", sm: "size-4", md: "size-4", lg: "size-[18px]", xl: "size-5" };
const spinnerSize: Record<ButtonSize, SpinnerSize> = { xs: "xs", sm: "xs", md: "sm", lg: "sm", xl: "md" };

function isComponent(icon: ButtonIcon): icon is ButtonIconComponent {
  if (typeof icon === "function") return true;
  // forwardRef / memo components (lucide icons) are objects carrying $$typeof but are not elements.
  return typeof icon === "object" && icon !== null && !React.isValidElement(icon) && "$$typeof" in icon;
}

function renderIcon(icon: ButtonIcon | undefined, className: string) {
  if (icon == null || icon === false) return null;
  if (isComponent(icon)) return React.createElement(icon, { className, "aria-hidden": true });
  return <span className={cn("inline-flex shrink-0 [&>svg]:size-full", className)}>{icon}</span>;
}

function Trailing({ trailing, size }: { trailing: ButtonProps["trailing"]; size: ButtonSize }) {
  if (!trailing) return null;
  const s = iconClass[size];
  if (trailing === "arrow")
    return <ArrowRight aria-hidden className={cn(s, "transition-transform duration-300 ease-[var(--ease-spring)] group-hover/btn:translate-x-[3px]")} />;
  if (trailing === "external")
    return <ArrowUpRight aria-hidden className={cn(s, "transition-transform duration-300 ease-[var(--ease-spring)] group-hover/btn:-translate-y-0.5 group-hover/btn:translate-x-0.5")} />;
  return <>{trailing}</>;
}

/** Shared inner layout: leading icon (or spinner), label, trailing glyphs, and the centred spinner overlay. */
function ButtonInner({
  size,
  loading,
  showTextWhileLoading,
  iconLeading,
  iconTrailing,
  trailing,
  children,
}: Required<Pick<ButtonVisualProps, "size">> & Omit<ButtonVisualProps, "size" | "variant" | "iconOnly" | "block">) {
  const reduce = useReducedMotion();
  const inline = loading && showTextWhileLoading;
  const s = iconClass[size];
  const spin = (
    <motion.span
      key="spin"
      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(4px)" }}
      animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
      exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(4px)" }}
      transition={{ type: "spring", stiffness: 520, damping: 30 }}
      className="inline-flex"
    >
      <Spinner size={spinnerSize[size]} label={null} />
    </motion.span>
  );
  const leading = iconLeading ? renderIcon(iconLeading, cn(s, "transition-transform duration-300 ease-[var(--ease-spring)] group-hover/btn:scale-110")) : null;
  return (
    <>
      <span
        className={cn(
          "inline-flex items-center gap-[inherit] transition-[opacity,filter] duration-200",
          loading && !inline && "opacity-0 blur-[2px]",
        )}
      >
        {inline ? <AnimatePresence initial={false}>{spin}</AnimatePresence> : leading}
        {children}
        {renderIcon(iconTrailing, cn(s, "transition-transform duration-300 ease-[var(--ease-spring)] group-hover/btn:translate-x-0.5"))}
        <Trailing trailing={trailing} size={size} />
      </span>
      <AnimatePresence initial={false}>
        {loading && !inline && (
          <motion.span key="overlay" className="absolute inset-0 grid place-items-center" aria-hidden>
            {spin}
          </motion.span>
        )}
      </AnimatePresence>
    </>
  );
}

/** Anchor that looks like a Button: same variants, sizes, icons and spring press. */
export const ButtonLink = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(function ButtonLink(
  { variant = "primary", size = "md", trailing, iconLeading, iconTrailing, loading, showTextWhileLoading, iconOnly, block, className, children, disabled, href, onClick, ...props },
  ref,
) {
  const inert = disabled || loading;
  const link = isLinkVariant(variant);
  return (
    <motion.a
      ref={ref}
      href={inert ? undefined : href}
      aria-disabled={inert || undefined}
      aria-busy={loading || undefined}
      data-loading={loading ? "" : undefined}
      whileTap={inert || link ? undefined : { scale: 0.96 }}
      transition={{ type: "spring", stiffness: 520, damping: 26 }}
      onClick={(e) => {
        if (inert) {
          e.preventDefault();
          return;
        }
        onClick?.(e);
      }}
      className={buttonClasses({ variant, size, iconOnly, block, className: cn(disabled && "pointer-events-none opacity-45", className) })}
      {...props}
    >
      <ButtonInner
        size={size}
        loading={loading}
        showTextWhileLoading={showTextWhileLoading}
        iconLeading={iconLeading}
        iconTrailing={iconTrailing}
        trailing={trailing}
      >
        {children}
      </ButtonInner>
    </motion.a>
  );
});

/**
 * Pill button with a spring press (scale .96), leading/trailing icons, a Spinner loading state and five sizes.
 * Pass `href` to render an anchor with the same look.
 */
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  {
    variant = "primary",
    size = "md",
    trailing,
    iconLeading,
    iconTrailing,
    loading,
    showTextWhileLoading,
    iconOnly,
    block,
    className,
    children,
    disabled,
    type = "button",
    href,
    onClick,
    ...props
  },
  ref,
) {
  if (href !== undefined) {
    // Button-only attributes are dropped; everything else (aria-*, data-*, handlers, target, rel) carries over.
    const { form: _form, formAction: _fa, formMethod: _fm, formNoValidate: _fnv, formTarget: _ft, name: _n, value: _v, ...rest } = props;
    void [_form, _fa, _fm, _fnv, _ft, _n, _v];
    return (
      <ButtonLink
        ref={ref as unknown as React.Ref<HTMLAnchorElement>}
        href={href}
        variant={variant}
        size={size}
        trailing={trailing}
        iconLeading={iconLeading}
        iconTrailing={iconTrailing}
        loading={loading}
        showTextWhileLoading={showTextWhileLoading}
        iconOnly={iconOnly}
        block={block}
        disabled={disabled}
        className={className}
        onClick={onClick as unknown as React.MouseEventHandler<HTMLAnchorElement>}
        {...(rest as unknown as Omit<ButtonLinkProps, "href">)}
      >
        {children}
      </ButtonLink>
    );
  }
  const link = isLinkVariant(variant);
  return (
    <motion.button
      ref={ref}
      type={type}
      whileTap={link ? undefined : { scale: 0.96 }}
      transition={{ type: "spring", stiffness: 520, damping: 26 }}
      disabled={disabled}
      aria-disabled={loading || undefined}
      aria-busy={loading || undefined}
      data-loading={loading ? "" : undefined}
      onClick={(e) => {
        // Loading keeps focus (no native disabled) but still blocks presses and implicit form submits.
        if (loading) {
          e.preventDefault();
          return;
        }
        onClick?.(e);
      }}
      className={buttonClasses({ variant, size, iconOnly, block, className })}
      {...props}
    >
      <ButtonInner
        size={size}
        loading={loading}
        showTextWhileLoading={showTextWhileLoading}
        iconLeading={iconLeading}
        iconTrailing={iconTrailing}
        trailing={trailing}
      >
        {children}
      </ButtonInner>
    </motion.button>
  );
});

export interface StatefulButtonProps extends Omit<ButtonProps, "onClick" | "loading"> {
  /** Resolve to show the success state, reject to shake back. */
  onAction: () => Promise<unknown>;
  successLabel?: React.ReactNode;
}

/** Button that runs an async action: label → spinner → check, with a width spring between states. */
export function StatefulButton({ onAction, successLabel = "Done", children, ...props }: StatefulButtonProps) {
  const [state, setState] = React.useState<"idle" | "loading" | "success" | "error">("idle");
  async function run() {
    if (state === "loading") return;
    setState("loading");
    try {
      await onAction();
      setState("success");
      setTimeout(() => setState("idle"), 1800);
    } catch {
      setState("error");
      setTimeout(() => setState("idle"), 600);
    }
  }
  return (
    <motion.div animate={state === "error" ? { x: [0, -6, 6, -3, 3, 0] } : { x: 0 }} transition={{ duration: 0.36 }} className="inline-flex">
      {/* The button springs its own width; the label is layout-corrected so it doesn't squash mid-resize. */}
      <Button layout {...props} loading={state === "loading"} onClick={run}>
        <motion.span key={state === "success" ? "success" : "label"} layout="position" className="inline-flex items-center gap-[inherit]">
          {state === "success" ? (
            <>
              <Check className="size-4" aria-hidden />
              {successLabel}
            </>
          ) : (
            children
          )}
        </motion.span>
      </Button>
    </motion.div>
  );
}

/** Button that leans toward the cursor while hovered and springs home on leave. */
export function MagneticButton({ strength = 0.35, className, ...props }: ButtonProps & { strength?: number }) {
  const ref = React.useRef<HTMLDivElement>(null);
  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const sx = useSpring(x, { stiffness: 260, damping: 18 });
  const sy = useSpring(y, { stiffness: 260, damping: 18 });
  return (
    <motion.div
      ref={ref}
      style={{ x: sx, y: sy }}
      className="inline-flex"
      onPointerMove={(e) => {
        const r = ref.current?.getBoundingClientRect();
        if (!r) return;
        x.set((e.clientX - (r.left + r.width / 2)) * strength);
        y.set((e.clientY - (r.top + r.height / 2)) * strength);
      }}
      onPointerLeave={() => {
        x.set(0);
        y.set(0);
      }}
    >
      <Button className={className} {...props} />
    </motion.div>
  );
}

```

### components/ui/button-classes.ts

```tsx
import { cn } from "@/lib/utils";

const variants = {
  primary: "bg-volt text-on-volt hover:shadow-glow",
  secondary: "bg-surface text-ink border border-border hover:bg-surface-raised hover:border-border-strong",
  outline: "bg-transparent text-ink border border-border-strong hover:bg-surface-hover",
  ghost: "bg-transparent text-ink-muted hover:bg-surface-hover hover:text-ink",
  inverse: "bg-ink text-bg hover:opacity-90",
  pro: "bg-ember text-on-ember hover:shadow-[0_8px_28px_var(--ember-soft)]",
  /** Outlined destructive action (kept for compatibility). */
  danger: "bg-transparent text-danger border border-danger hover:bg-surface-hover",
  /** Solid destructive action: the final "Delete" in a confirm dialog. */
  destructive: "bg-danger text-bg hover:shadow-[0_8px_28px_var(--danger-soft)]",
  /** Quiet destructive action: "Remove" in a list row. */
  "destructive-ghost": "bg-transparent text-danger hover:bg-danger-soft",
  /** Inline volt link; the underline draws left to right on hover. */
  link: "text-volt-ink",
  /** Inline muted link that brightens to ink on hover. */
  "link-muted": "text-ink-muted hover:text-ink",
  /** Inline destructive link. */
  "link-destructive": "text-danger",
} as const;

const sizes = {
  xs: "h-7 px-2.5 text-xs gap-1",
  sm: "h-8 px-3.5 text-[13px] gap-1.5",
  md: "h-10 px-[18px] text-sm gap-2",
  lg: "h-12 px-6 text-[15px] gap-2",
  xl: "h-14 px-7 text-base gap-2.5",
} as const;

const iconSizes = { xs: "size-7", sm: "size-8", md: "size-10", lg: "size-12", xl: "size-14" } as const;

/** Link variants keep only the type scale: no height, padding or pill. */
const linkSizes = { xs: "text-xs gap-1", sm: "text-[13px] gap-1", md: "text-sm gap-1.5", lg: "text-[15px] gap-1.5", xl: "text-base gap-2" } as const;

export type ButtonVariant = keyof typeof variants;
export type ButtonSize = keyof typeof sizes;

/** True for the inline text-link variants (no padding, underline on hover). */
export function isLinkVariant(variant: ButtonVariant) {
  return variant === "link" || variant === "link-muted" || variant === "link-destructive";
}

/** Class string for anything that should look like a GetUrUI button (links included). Safe to call on the server. */
export function buttonClasses({
  variant = "primary",
  size = "md",
  iconOnly,
  block,
  className,
}: { variant?: ButtonVariant; size?: ButtonSize; iconOnly?: boolean; block?: boolean; className?: string }) {
  const link = isLinkVariant(variant);
  return cn(
    "group/btn relative inline-flex shrink-0 select-none items-center justify-center whitespace-nowrap rounded-full font-medium tracking-[-0.1px]",
    "transition-[background-color,box-shadow,color,border-color,opacity] duration-150 disabled:pointer-events-none disabled:opacity-45",
    "data-[loading]:pointer-events-none",
    "focus-visible:shadow-[var(--focus-ring)] outline-none",
    variants[variant],
    link
      ? cn(
          "h-auto rounded-xs p-0 transition-[background-size,color,opacity] duration-300 ease-[var(--ease-spring)]",
          "[background-image:linear-gradient(currentColor,currentColor)] bg-no-repeat [background-position:0_100%] [background-size:0%_1px] hover:[background-size:100%_1px]",
          linkSizes[size],
        )
      : iconOnly
        ? cn(iconSizes[size], "p-0")
        : sizes[size],
    block && "w-full",
    className,
  );
}

```
