docs / core / button

Button

the most-used thing in the system, so it does the least. one solid per region carries the action; everything else steps back. a button that performs is a button competing with the thing you clicked it for.

intensity · recedesrsc · client

provenance

shaped from personal-website · sisu-plus

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

sits inside Card, PageHeader, Toolbar, Panel, Dialog footerasChild puts the skin on a link when the action navigatesnever inside another Button, never as a row's only click targetdanger styling is not a variant. destruction gets a Dialog first

a11y

focus ring on --ring · loading sets aria-busy, not disabled · the label never emptiesjest-axe

dependencies

motion · class-variance-authority · Spinner from the same package
live demo · try it out
customize
variantvisual weight
sizedensity
shapepill fully rounds it
icon onlysquare, needs aria-label
loadingstill focusable
disabledprefer an inline error
usagecopy
import { Button } from "usva/primitives/button";

<Button>Save changes</Button>

props

proptypedefaultnotes
variant"solid" | "soft" | "outline" | "ghost" | "onSurface" | "glass""solid"visual weight. one solid per region. onSurface is a theme-tonal fill for a button on a surface or gradient you own; it adapts with the theme. glass is a fixed dark frost, blurred, for a control floating over a live atmosphere, where the theme cannot guarantee contrast.
size"sm" | "md" | "lg""md"sm exists for dense rows. it is a smaller button, not a subtler one.
shape"rounded" | "pill""rounded"pill fully rounds the button into a chip. the default follows the size radius.
asChildbooleanfalsemerges props onto the single child instead of rendering a <button>. how a link earns the button skin.
disabledbooleanfalsedims to 50% and drops pointer events. loading is a status, not this.
status"idle" | "loading" | "success" | "error""idle"the content machine. keeps its width: loading swaps in the spinner, success and error flash their icon and settle back to idle.
loadingText / successText / errorTextReactNodethe label beside the spinner, check and alert for each non-idle status.
iconOnlybooleanfalsea square button for one glyph. requires aria-label, it throws in dev without one.
tooltip / sideReactNode · "top" | "bottom" | "left" | "right"a visible label on hover and focus. what icon-only buttons use instead of text.
activebooleanfalsethe pressed look, for toggles.
settleDelaynumber1200how long success or error holds before returning to idle.
onSettle() => voidfires when a success or error settles back to idle.

get it

npx shadcn add https://usva.build/r/button.jsoncopy
source · components/ui/button.tsxexactly what this command copiescopy
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
import { Spinner } from "./spinner";

declare const process: { env: { NODE_ENV?: string } };

export type ButtonStatus = "idle" | "loading" | "success" | "error";

export const buttonVariants = cva(
  cn(
    "relative isolate inline-flex cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg font-medium tracking-[-0.01em] outline-none",
    "transition-control duration-fast ease-soft",
    "hover:-translate-y-px active:scale-[0.96] motion-reduce:transition-none motion-reduce:transform-none",
    "before:pointer-events-none before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:bg-transparent before:transition-tint before:duration-fast",
    "after:absolute after:inset-x-0 after:content-['']",
    "focus-visible:ring-focus",
    "disabled:pointer-events-none disabled:opacity-50 disabled:saturate-[0.7]",
    "data-[status=loading]:pointer-events-none",
    "data-[status=success]:bg-none data-[status=success]:bg-success data-[status=success]:text-on-accent",
    "data-[status=error]:bg-none data-[status=error]:bg-danger data-[status=error]:text-on-accent",
    "[&_svg]:pointer-events-none [&_svg]:shrink-0",
  ),
  {
    variants: {
      variant: {
        solid:
          "bg-accent bg-gradient-accent font-semibold text-on-accent shadow-raised hover:glow-ring hover:before:bg-ink/10 active:before:bg-ink/5",
        soft: "bg-surface-2 text-ink shadow-raised hover:before:bg-ink/5",
        ghost: "bg-transparent text-muted hover:text-ink hover:before:bg-ink/5",
        outline:
          "border border-border bg-transparent text-ink hover:border-border-strong hover:before:bg-ink/5 focus-visible:border-transparent",
        onSurface:
          "border border-ink/10 bg-ink/[0.055] font-semibold text-ink hover:border-ink/20 hover:before:bg-ink/5",
        glass:
          "border border-white/15 bg-black/40 text-white/90 shadow-raised backdrop-blur-sm hover:bg-black/55 hover:before:bg-white/5",
      },
      size: {
        sm: "h-8 gap-1.5 rounded-md px-3 text-xs after:-inset-y-1.5",
        md: "h-10 px-4 text-sm after:-inset-y-0.5",
        lg: "h-12 rounded-xl px-6 text-[0.9375rem] after:inset-y-0",
      },
      iconOnly: { true: "px-0", false: "" },
      active: { true: "glow-ring text-accent", false: "" },
      shape: { rounded: "", pill: "" },
    },
    compoundVariants: [
      // the `after` inset expands the hit area to 44px without growing the box,
      // so it has to scale inversely with the visual size
      {
        iconOnly: true,
        size: "sm",
        class: "w-8 rounded-lg after:-inset-1.5 [&_svg]:size-4",
      },
      {
        iconOnly: true,
        size: "md",
        class: "w-10 rounded-xl after:-inset-0.5 [&_svg]:size-[1.15rem]",
      },
      { iconOnly: true, size: "lg", class: "w-12 [&_svg]:size-5" },
      // icon-only outline keeps the quiet reading the old IconButton had
      {
        iconOnly: true,
        variant: "outline",
        active: false,
        class: "bg-surface text-muted hover:text-ink",
      },
      { active: true, variant: "outline", class: "border-transparent" },
      { shape: "pill", class: "rounded-full" },
    ],
    defaultVariants: {
      variant: "solid",
      size: "md",
      iconOnly: false,
      active: false,
      shape: "rounded",
    },
  },
);

const TOOLTIP_SIDE: Record<string, string> = {
  top: "bottom-full left-1/2 mb-2 -translate-x-1/2",
  bottom: "top-full left-1/2 mt-2 -translate-x-1/2",
  left: "right-full top-1/2 mr-2 -translate-y-1/2",
  right: "left-full top-1/2 ml-2 -translate-y-1/2",
};

const SPINNER_SIZE = { sm: "sm", md: "sm", lg: "md" } as const;

const GRID_STYLE: React.CSSProperties = {
  display: "grid",
  placeItems: "center",
};
const CELL_STYLE: React.CSSProperties = { gridArea: "1 / 1" };
const SIZER_STYLE: React.CSSProperties = {
  ...CELL_STYLE,
  visibility: "hidden",
};
// Inline, not `sr-only`: a consumer's Tailwind build never scans this file.
const SR_ONLY_STYLE: React.CSSProperties = {
  position: "absolute",
  width: 1,
  height: 1,
  margin: -1,
  padding: 0,
  overflow: "hidden",
  clipPath: "inset(50%)",
  whiteSpace: "nowrap",
  border: 0,
};

type MotionConflicts =
  | "onAnimationStart"
  | "onAnimationEnd"
  | "onDrag"
  | "onDragStart"
  | "onDragEnd";

export interface ButtonProps
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, MotionConflicts>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
  status?: ButtonStatus;
  loadingText?: React.ReactNode;
  successText?: React.ReactNode;
  errorText?: React.ReactNode;
  /** How long `success` / `error` hold before the button settles back to idle. */
  settleDelay?: number;
  onSettle?: () => void;
  /** Optional visible tooltip on hover/focus. */
  tooltip?: React.ReactNode;
  side?: "top" | "bottom" | "left" | "right";
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      className,
      variant,
      size = "md",
      iconOnly = false,
      active = false,
      shape,
      asChild,
      status = "idle",
      loadingText,
      successText,
      errorText,
      settleDelay = 1200,
      onSettle,
      tooltip,
      side = "top",
      onClick,
      children,
      ...props
    },
    ref,
  ) => {
    const reduce = useReducedMotion();
    const display = useSettlingStatus(status, settleDelay, onSettle);
    const tooltipId = React.useId();
    const busy = display === "loading";

    if (
      process.env.NODE_ENV !== "production" &&
      iconOnly &&
      !props["aria-label"] &&
      !props["aria-labelledby"]
    ) {
      throw new Error("usva: an icon-only Button needs an aria-label.");
    }

    if (asChild)
      return (
        <Slot
          {...props}
          slotRef={ref}
          onClick={onClick}
          className={cn(
            buttonVariants({ variant, size, iconOnly, active, shape }),
            className,
          )}
        >
          {children}
        </Slot>
      );

    const content: Record<ButtonStatus, React.ReactNode> = {
      idle: children,
      loading: (
        <>
          <Spinner
            aria-hidden="true"
            label=""
            tone="current"
            size={SPINNER_SIZE[size ?? "md"]}
          />
          {loadingText ?? (iconOnly ? null : "Loading")}
        </>
      ),
      success: (
        <>
          <CheckIcon />
          {successText ?? <span style={SR_ONLY_STYLE}>{children}</span>}
        </>
      ),
      error: (
        <>
          <AlertIcon />
          {errorText ?? <span style={SR_ONLY_STYLE}>{children}</span>}
        </>
      ),
    };

    // The lift is declared twice on purpose. Motion writes an inline transform on the
    // first press and never gives it back, which outranks the `hover:-translate-y-px`
    // class from here on. That class is what the motion-free `asChild` path uses.
    const button = (
      <motion.button
        ref={ref}
        data-status={display}
        aria-busy={busy || undefined}
        aria-describedby={tooltip ? tooltipId : undefined}
        whileHover={reduce ? undefined : { y: -1 }}
        whileTap={reduce ? undefined : { scale: 0.96, y: 0 }}
        transition={
          reduce
            ? { duration: 0 }
            : { type: "spring", stiffness: 420, damping: 34 }
        }
        onClick={busy ? undefined : onClick}
        className={cn(
          buttonVariants({ variant, size, iconOnly, active, shape }),
          className,
        )}
        {...props}
      >
        {/* Every state is stacked in one grid cell so the button reserves the widest
            state's width up front and never reshapes between them. Only the active
            label is shown; the rest are hidden sizers. Grid placement is inline-styled,
            not Tailwind, so a consumer's build can never tree-shake the layout away. */}
        <span style={GRID_STYLE}>
          {(Object.keys(content) as ButtonStatus[]).map((s) => (
            <span
              key={s}
              aria-hidden
              style={SIZER_STYLE}
              className="inline-flex items-center gap-2 whitespace-nowrap"
            >
              {content[s]}
            </span>
          ))}
          <motion.span
            key={display}
            style={CELL_STYLE}
            initial={reduce ? false : { opacity: 0, scale: 0.96 }}
            animate={{ opacity: 1, scale: 1 }}
            transition={
              reduce
                ? { duration: 0 }
                : { duration: 0.2, ease: [0.22, 1, 0.36, 1] }
            }
            className="inline-flex items-center gap-2 whitespace-nowrap"
          >
            {content[display]}
          </motion.span>
        </span>
      </motion.button>
    );

    if (!tooltip) return button;
    return (
      <span className="group relative isolate inline-flex">
        {button}
        <span
          id={tooltipId}
          role="tooltip"
          className={cn(
            "pointer-events-none absolute z-overlay whitespace-nowrap rounded-md border border-border bg-overlay px-2 py-1 font-mono text-[0.625rem] font-semibold uppercase tracking-[0.08em] text-ink shadow-floating",
            "opacity-0 transition-opacity duration-fast ease-soft group-hover:opacity-100 group-focus-within:opacity-100 motion-reduce:transition-none",
            TOOLTIP_SIDE[side],
          )}
        >
          {tooltip}
        </span>
      </span>
    );
  },
);
Button.displayName = "Button";

/**
 * `success` and `error` are momentary: they hold for `settleDelay`, then the button
 * returns to idle on its own. The callback lives in a ref so an inline `onSettle`
 * doesn't restart the timer on every render.
 */
function useSettlingStatus(
  status: ButtonStatus,
  settleDelay: number,
  onSettle?: () => void,
): ButtonStatus {
  const [display, setDisplay] = React.useState<ButtonStatus>(status);
  const settle = React.useRef(onSettle);

  React.useEffect(() => {
    settle.current = onSettle;
  }, [onSettle]);

  React.useEffect(() => {
    setDisplay(status);
    if (status !== "success" && status !== "error") return;
    const timer = setTimeout(() => {
      setDisplay("idle");
      settle.current?.();
    }, settleDelay);
    return () => clearTimeout(timer);
  }, [status, settleDelay]);

  return display;
}

function CheckIcon() {
  return (
    <svg
      viewBox="0 0 12 12"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden="true"
    >
      <path d="M2.5 6.5 5 9l4.5-5.5" />
    </svg>
  );
}

function AlertIcon() {
  return (
    <svg
      viewBox="0 0 12 12"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className="h-4 w-4"
      aria-hidden="true"
    >
      <path d="M6 3v3.5" />
      <path d="M6 8.75h.01" />
    </svg>
  );
}

type Handler = (...args: unknown[]) => void;

function childRef(element: React.ReactElement): React.Ref<unknown> | undefined {
  return Number.parseInt(React.version, 10) >= 19
    ? (element.props as { ref?: React.Ref<unknown> }).ref
    : (element as unknown as { ref?: React.Ref<unknown> }).ref;
}

/**
 * Carries the ref under a name of its own. React 18 reserves `ref`, strips it
 * from a function component's props and warns, so a Slot that read `props.ref`
 * would forward nothing there while working on 19.
 */
function Slot({
  children,
  className,
  style,
  slotRef,
  ...props
}: React.ButtonHTMLAttributes<HTMLElement> & {
  children?: React.ReactNode;
  slotRef?: React.Ref<unknown>;
}) {
  const element = React.isValidElement<React.HTMLAttributes<HTMLElement>>(
    children,
  )
    ? children
    : null;
  const theirRef = element ? childRef(element) : undefined;

  const composedRef = React.useCallback(
    (node: unknown) => {
      for (const target of [slotRef, theirRef]) {
        if (typeof target === "function") target(node);
        else if (target)
          (target as React.MutableRefObject<unknown>).current = node;
      }
    },
    [slotRef, theirRef],
  );

  if (!element) return null;
  const childProps = element.props as Record<string, unknown>;
  const merged: Record<string, unknown> = { ...props, ...childProps };

  for (const [key, ours] of Object.entries(props)) {
    if (!key.startsWith("on") || typeof ours !== "function") continue;
    const theirs = childProps[key];
    merged[key] =
      typeof theirs === "function"
        ? (...args: unknown[]) => {
            (theirs as Handler)(...args);
            (ours as Handler)(...args);
          }
        : ours;
  }

  merged.className = cn(className, childProps.className as string);
  merged.style = { ...style, ...(childProps.style as React.CSSProperties) };
  merged.ref = composedRef;

  return React.cloneElement(
    element,
    merged as React.HTMLAttributes<HTMLElement>,
  );
}