FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Loading Button

Loading Button

pnpm dlx shadcn@latest add …/r/loading-button.json

An accessible React loading button for Next.js that shows loading, success and error states for async actions, without changing width.

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

export function SaveButton() {
  return (
    <LoadingButton
      onClick={() => saveChanges()}
      loadingText="Saving…"
      successText="Saved"
      errorText="Try again"
    >
      Save changes
    </LoadingButton>
  )
}
async.tsx
"use client";

import { LoadingButton } from "@/components/ui/loading-button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { useId, useState } from "react";

const delays = [
  { ms: "800", label: "Quick · 0.8s" },
  { ms: "1500", label: "Medium · 1.5s" },
  { ms: "3000", label: "Slow · 3s" },
];

/** Pretend work that settles after `ms`, one way or the other */
const work = (ms: number, succeed: boolean) =>
  new Promise<void>((resolve, reject) =>
    setTimeout(() => (succeed ? resolve() : reject(new Error("Failed"))), ms),
  );

export default function LoadingButtonAsync() {
  const id = useId();
  const [delay, setDelay] = useState("1500");

  return (
    <div className="flex w-full flex-col gap-6 self-stretch">
      <div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
        <label htmlFor={`${id}-delay`} className="text-sm text-muted-foreground">
          Work takes
        </label>
        <Select value={delay} onValueChange={setDelay}>
          <SelectTrigger id={`${id}-delay`} size="sm" className="w-36">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {delays.map((option) => (
              <SelectItem key={option.ms} value={option.ms}>
                {option.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </div>

      <div className="flex flex-1 flex-wrap items-start justify-center gap-x-6 gap-y-4">
        <div className="flex flex-col items-center gap-1.5">
          <LoadingButton
            onClick={() => work(Number(delay), true)}
            loadingText="Saving…"
            successText="Saved"
            aria-describedby={`${id}-saves`}
          >
            Save changes
          </LoadingButton>
          <span id={`${id}-saves`} className="text-xs text-muted-foreground">
            Succeeds
          </span>
        </div>
        <div className="flex flex-col items-center gap-1.5">
          <LoadingButton
            variant="outline"
            onClick={() => work(Number(delay), false)}
            loadingText="Publishing…"
            errorText="Try again"
            aria-describedby={`${id}-fails`}
          >
            Publish
          </LoadingButton>
          <span id={`${id}-fails`} className="text-xs text-muted-foreground">
            Fails
          </span>
        </div>
      </div>

      <div aria-hidden className="-mb-6 h-(--size-control-sm)" />
    </div>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add motion
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Add Button first.

  5. 5

    Copy the source into your project.

    components/ui/loading-button.tsx
    "use client";
    
    import { Button, type ButtonProps } from "@/components/ui/button";
    import { duration, ease, spring, swap } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { type MouseEvent, type ReactNode, useEffect, useRef, useState } from "react";
    
    /*
     * A Button that shows the work it starts: idle, loading, success, error (owner, 2026-10-14; Lab only
     * until it's approved, so there's no meta file yet).
     *
     * - State: control it with `state`, or leave it out and return a promise from `onClick`. Loading lasts
     *   until the promise settles, then success or error shows, and the button returns to idle after
     *   `resetAfter`.
     * - Motion (.agents/rules/motion.md): the content swaps through a blur with a small rise (owner,
     *   2026-10-14): the old blurs and lifts away as the new rises in from blurred. The spinner turns; the
     *   check and cross draw themselves in. With reduced motion the content only fades and the spinner pulses.
     * - Width never jumps: every state is laid out invisibly in the same cell, so the button is as wide as its
     *   widest state.
     * - Error is solid red (owner, 2026-10-14), just the text on ghost and link. Success stays neutral, with
     *   the check carrying the meaning.
     * - Busy, it stays focusable (aria-disabled, not disabled) and ignores clicks, and a polite live region
     *   says what happened.
     */
    
    export type LoadingButtonState = "idle" | "loading" | "success" | "error";
    
    export type LoadingButtonProps = Omit<ButtonProps, "onClick" | "asChild"> & {
      /** Control the state yourself. Leave it out and a click that returns a promise drives it. */
      state?: LoadingButtonState;
      /** Return a promise to show loading until it settles, then success or error */
      onClick?: (event: MouseEvent<HTMLButtonElement>) => unknown;
      /** Shown while loading, next to the spinner; the label stays if left out */
      loadingText?: ReactNode;
      /** Shown on success, next to the check */
      successText?: ReactNode;
      /** Shown on error, next to the cross */
      errorText?: ReactNode;
      /** After success or error, milliseconds until it's idle again; `false` to stay. Ignored when controlled. */
      resetAfter?: number | false;
    };
    
    const CHECK = "M5 12.5l4.5 4.5L19 7.5";
    const CROSS = "M7 7l10 10M17 7L7 17";
    
    /** A ring with a turning arc; with reduced motion it pulses instead */
    function Spinner({ reduce }: { reduce: boolean }) {
      return (
        <motion.svg
          viewBox="0 0 24 24"
          fill="none"
          aria-hidden
          animate={reduce ? { opacity: [1, 0.4, 1] } : { rotate: 360 }}
          transition={
            reduce
              ? { duration: duration.slower * 3, repeat: Number.POSITIVE_INFINITY, ease: ease.inOut }
              : // One turn a second, at a constant speed
                { duration: duration.slower * 2, repeat: Number.POSITIVE_INFINITY, ease: "linear" }
          }
        >
          <circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" opacity="0.25" />
          <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
        </motion.svg>
      );
    }
    
    /** A stroke that draws itself in (the check or the cross); a 16px icon, so drawing it costs nothing */
    function Mark({ d, reduce }: { d: string; reduce: boolean }) {
      return (
        <svg viewBox="0 0 24 24" fill="none" aria-hidden>
          <motion.path
            d={d}
            stroke="currentColor"
            strokeWidth="2.5"
            strokeLinecap="round"
            strokeLinejoin="round"
            initial={{ pathLength: reduce ? 1 : 0 }}
            animate={{ pathLength: 1 }}
            transition={{ duration: duration.slow, ease: ease.out, delay: reduce ? 0 : duration.instant }}
          />
        </svg>
      );
    }
    
    const isPromise = (value: unknown): value is PromiseLike<unknown> =>
      typeof (value as PromiseLike<unknown> | null)?.then === "function";
    
    /** What a state shows. Sizers get a blank square for the icon, so nothing animates out of sight. */
    function contentOf(
      state: LoadingButtonState,
      {
        children,
        loadingText,
        successText,
        errorText,
      }: Pick<LoadingButtonProps, "children" | "loadingText" | "successText" | "errorText">,
      icons: { reduce: boolean } | null,
    ) {
      const icon = (live: ReactNode) => (icons ? live : <span className="size-(--size-icon-md) shrink-0" />);
      switch (state) {
        case "loading":
          return (
            <>
              {icon(<Spinner reduce={icons?.reduce ?? false} />)}
              {loadingText ?? children}
            </>
          );
        case "success":
          return (
            <>
              {icon(<Mark d={CHECK} reduce={icons?.reduce ?? false} />)}
              {successText ?? children}
            </>
          );
        case "error":
          return (
            <>
              {icon(<Mark d={CROSS} reduce={icons?.reduce ?? false} />)}
              {errorText ?? children}
            </>
          );
        default:
          return children;
      }
    }
    
    const STATES: LoadingButtonState[] = ["idle", "loading", "success", "error"];
    const ANNOUNCEMENTS: Record<LoadingButtonState, string> = {
      idle: "",
      loading: "Loading",
      success: "Done",
      error: "Something went wrong",
    };
    
    function LoadingButton({
      state,
      onClick,
      children,
      loadingText,
      successText,
      errorText,
      resetAfter = 2000,
      variant,
      className,
      ...props
    }: LoadingButtonProps) {
      const [internal, setInternal] = useState<LoadingButtonState>("idle");
      const current = state ?? internal;
      const reduce = useReducedMotion() ?? false;
      const mounted = useRef(true);
      const texts = { children, loadingText, successText, errorText };
    
      useEffect(() => {
        mounted.current = true;
        return () => {
          mounted.current = false;
        };
      }, []);
    
      // Uncontrolled, success and error give way to idle after a moment
      useEffect(() => {
        if (state !== undefined || resetAfter === false) return;
        if (internal !== "success" && internal !== "error") return;
        const timer = setTimeout(() => setInternal("idle"), resetAfter);
        return () => clearTimeout(timer);
      }, [internal, state, resetAfter]);
    
      const busy = current === "loading";
      // Error is solid red (owner, 2026-10-14). Ghost and link keep their surface, so they turn only their text red.
      const errorClass =
        variant === "ghost" || variant === "link"
          ? "text-destructive"
          : "border-transparent bg-destructive text-destructive-foreground";
      // With reduced motion: a plain fade, no blur or movement
      const motionProps = reduce
        ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
        : swap;
    
      return (
        <>
          <Button
            data-slot="loading-button"
            data-state={current}
            variant={variant}
            aria-busy={busy || undefined}
            aria-disabled={busy || undefined}
            className={cn(current === "error" && errorClass, busy && "cursor-progress", className)}
            onClick={(event) => {
              if (busy) {
                event.preventDefault();
                return;
              }
              const result = onClick?.(event);
              if (state !== undefined || !isPromise(result)) return;
              setInternal("loading");
              result.then(
                () => mounted.current && setInternal("success"),
                () => mounted.current && setInternal("error"),
              );
            }}
            {...props}
          >
            <span className="grid">
              {/* Every state, invisible, in one cell: the button is as wide as the widest */}
              {STATES.map((name) => (
                <span
                  key={name}
                  aria-hidden
                  className="invisible col-start-1 row-start-1 flex items-center justify-center gap-2"
                >
                  {contentOf(name, texts, null)}
                </span>
              ))}
              <AnimatePresence initial={false}>
                <motion.span
                  key={current}
                  {...motionProps}
                  transition={
                    reduce
                      ? { duration: duration.fast }
                      : { duration: duration.base, ease: ease.out, y: spring.snappy }
                  }
                  className="col-start-1 row-start-1 flex items-center justify-center gap-2"
                >
                  {contentOf(current, texts, { reduce })}
                </motion.span>
              </AnimatePresence>
            </span>
          </Button>
          <span aria-live="polite" className="sr-only">
            {ANNOUNCEMENTS[current]}
          </span>
        </>
      );
    }
    
    export { LoadingButton };
  6. 6

    Update the import paths to match your project.

Examples

Every state

Set state yourself to control it. The width never changes, because the button is sized to its widest state.

  • Idle
  • LoadingLoading
  • DoneSuccess
  • Something went wrongError
states.tsx
import { LoadingButton, type LoadingButtonState } from "@/components/ui/loading-button";
import { useId } from "react";

const states: { state: LoadingButtonState; label: string }[] = [
  { state: "idle", label: "Idle" },
  { state: "loading", label: "Loading" },
  { state: "success", label: "Success" },
  { state: "error", label: "Error" },
];

export default function LoadingButtonStates() {
  const id = useId();
  return (
    <ul className="grid w-fit grid-cols-2 gap-x-4 gap-y-5 sm:grid-cols-4">
      {states.map(({ state, label }) => (
        <li key={state} className="flex flex-col items-center gap-1.5">
          <LoadingButton
            state={state}
            loadingText="Saving…"
            successText="Saved"
            errorText="Try again"
            aria-describedby={`${id}-${state}`}
          >
            Save changes
          </LoadingButton>
          <span id={`${id}-${state}`} className="text-xs text-muted-foreground">
            {label}
          </span>
        </li>
      ))}
    </ul>
  );
}

API reference

Also accepts every Button prop except asChild. While loading it stays focusable (aria-busy, aria-disabled) and ignores clicks, and a polite live region announces each change.

PropTypeDefaultDescription
onClick?(event) => unknown–Return a promise to show loading until it settles, then success when it resolves or error when it rejects.
state?"idle" | "loading" | "success" | "error"–Controls the state yourself. Leave it out and onClick's promise drives it.
loadingText?ReactNode–Shown next to the spinner. The label stays if left out.
successText?ReactNode–Shown next to the check.
errorText?ReactNode–Shown next to the cross, on solid red.
resetAfter?number | false2000Milliseconds after success or error until it's idle again, or false to stay. Ignored when controlled.
variant?Button variant"default"The button style.
size?Button size"md"The button size.

Related components

  • Button

    An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.

  • Theme Toggle

    An accessible React dark mode toggle for Next.js that reveals the new theme from the button with a View Transition: a circle, ink, a wave or a page turn.

    • Ghost
    • Outline
    • With text
    Copy Button

    An accessible React copy to clipboard button for Next.js: the copy icon turns into a check, and screen readers hear "Copied".