Skip to content
goodtabs UI
BrowseBrowse components

Loading button

A button with a pending state that shows a spinner and keeps its width.

npx shadcn@latest add https://ui.goodtabs.co/r/loading-button.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/loading-button

Click to load for 1.5 seconds. The width does not change.

With loading text

loadingText replaces the label while pending. The second one is stuck on, as a still state.

In a form footer

Rename project

The slug stays the same. Links keep working.

A submit button next to a cancel that locks while the form saves.

Usage

import { LoadingButton } from "@/components/ui/loading-button"

<LoadingButton type="submit" loading={pending} loadingText="Saving">
  Save
</LoadingButton>

Props

PropTypeDefaultDescription
loadingbooleanfalseShows the spinner, sets aria-busy and disables the button.
loadingTextstringReplaces the children while loading. Leave out to show only the spinner.
variant, size, typeButton props"default", "default", "button"Every shadcn Button prop passes through.

Source

loading-button/loading-button.tsx
import * as React from "react";
import { Loader2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

type LoadingButtonProps = React.ComponentProps<typeof Button> & {
  /** Shows the spinner, sets aria-busy and disables the button. */
  loading?: boolean;
  /** Replaces the children while loading, for example "Saving". */
  loadingText?: string;
};

/**
 * A button with a pending state. The children stay in the layout, hidden,
 * so the button keeps its width and nothing around it jumps.
 */
export function LoadingButton({
  loading = false,
  loadingText,
  disabled,
  type = "button",
  className,
  children,
  ...props
}: LoadingButtonProps) {
  return (
    <Button
      type={type}
      disabled={disabled || loading}
      aria-busy={loading || undefined}
      data-loading={loading || undefined}
      data-slot="loading-button"
      className={cn("inline-grid", className)}
      {...props}
    >
      <span
        className={cn(
          "col-start-1 row-start-1 inline-flex items-center justify-center gap-[inherit]",
          loading && "invisible",
        )}
      >
        {children}
      </span>
      {loading && (
        <span className="col-start-1 row-start-1 inline-flex items-center justify-center gap-[inherit]">
          <Loader2Icon className="animate-spin motion-reduce:animate-none" aria-hidden="true" />
          {loadingText}
        </span>
      )}
    </Button>
  );
}

For AI agents

Hand your agent this page, the markdown at /components/loading-button/prompt.md, or the Copy AI prompt button above.

When to use

  • Form submits and any action that takes more than a moment to settle
  • When the button sits in a row or footer where a width change would shift its neighbours
  • With useActionState or useTransition, passing pending straight into loading

When not to use

  • For actions that settle instantly; a spinner that flashes for 50ms reads as a glitch
  • To block a whole form: disable the fields too, or use a dialog with a pending state
  • For page level progress; use a progress bar or skeleton

Agent notes

  • Owns no state: pass loading from a transition, action state or a fetch, and the button handles aria-busy and disabled
  • The label stays in the layout with visibility hidden, so the width is at least the label's; a longer loadingText grows it
  • The spinner stops under prefers-reduced-motion and still reads as a loader icon
  • type defaults to button; pass type="submit" in forms