All patterns

Form Submit Progress

Submit narrows to a turning disc while the request runs, then opens out as the confirmed state.

feedbackpremiumcalminteraction · finite · advanced · ~2.4s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

286 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Form Submit Progress
 *
 * A submit control that narrows to a turning disc while the request is
 * in flight and opens back out as the confirmed state. The row it sits
 * in keeps its size throughout, so nothing under the button moves.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Works with zero props; tune via `variant`, `label`, `workMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type FormSubmitProgressProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Resting label. */
  label?: string;
  /** Label while the request is in flight — read out to assistive tech. */
  workingLabel?: string;
  /** Label once the request has landed. */
  doneLabel?: string;
  /** How long the sample request takes, in ms. */
  workMs?: number;
  /** How long the confirmed state is held before returning to rest, in ms. */
  resetMs?: number;
  /** Width of the resting control, in px. Also the reserved row width. */
  restWidth?: number;
  /** Width of the confirmed control, in px. */
  doneWidth?: number;
  /** Fires when the control is pressed. */
  onSubmit?: () => void;
  /** Fires when the confirmed state has been reached. */
  onDone?: () => void;
};

type Phase = "idle" | "working" | "done";

type VariantConfig = {
  spring: { type: "spring"; stiffness: number; damping: number };
  swapDuration: number;
  spinSeconds: number;
};

// The control carries a word while it changes size, so the width spring
// has to arrive without overshoot — every ratio here is at or above 0.8.
// Variants differ in how fast the collapse runs, never in its character.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Snaps down and back with no visible travel. For toolbars.
  subtle: {
    spring: { type: "spring", stiffness: 610, damping: 50 },
    swapDuration: 0.08,
    spinSeconds: 1,
  },
  default: {
    spring: { type: "spring", stiffness: 380, damping: 34 },
    swapDuration: 0.14,
    spinSeconds: 0.8,
  },
  // Slower, more deliberate collapse — for a single hero action.
  playful: {
    spring: { type: "spring", stiffness: 270, damping: 27 },
    swapDuration: 0.2,
    spinSeconds: 0.6,
  },
};

const ACCENT = "#7C7CF0";
const DONE = "#10B981";
const HEIGHT = 44;

export default function FormSubmitProgress({
  variant = "default",
  label = "Create invoice",
  workingLabel = "Creating invoice",
  doneLabel = "Invoice created",
  workMs = 1500,
  resetMs = 1900,
  restWidth = 200,
  doneWidth = 186,
  onSubmit,
  onDone,
}: FormSubmitProgressProps) {
  const [phase, setPhase] = useState<Phase>("idle");
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  // The callback lives in a ref so an inline arrow from the parent can't
  // re-trigger the effect and restart the request part-way through.
  const onDoneRef = useRef(onDone);
  useEffect(() => {
    onDoneRef.current = onDone;
  }, [onDone]);

  useEffect(() => {
    if (phase !== "working") return;
    const timer = setTimeout(() => {
      setPhase("done");
      onDoneRef.current?.();
    }, workMs);
    return () => clearTimeout(timer);
  }, [phase, workMs]);

  useEffect(() => {
    if (phase !== "done") return;
    const timer = setTimeout(() => setPhase("idle"), resetMs);
    return () => clearTimeout(timer);
  }, [phase, resetMs]);

  // Reduced motion: the control keeps its width and its position, and
  // the state is carried entirely by the label and the color. The
  // information survives; only the travel is dropped.
  const width = reduceMotion
    ? restWidth
    : phase === "working"
      ? HEIGHT
      : phase === "done"
        ? doneWidth
        : restWidth;

  const swap = { duration: cfg.swapDuration, ease: "easeOut" } as const;

  const ring = (
    <motion.svg
      width="19"
      height="19"
      viewBox="0 0 16 16"
      fill="none"
      aria-hidden
      animate={reduceMotion ? undefined : { rotate: 360 }}
      transition={{ duration: cfg.spinSeconds, ease: "linear", repeat: Infinity }}
    >
      <circle cx="8" cy="8" r="6.2" stroke="rgba(255,255,255,0.34)" strokeWidth="2" />
      <path
        d="M8 1.8a6.2 6.2 0 0 1 6.2 6.2"
        stroke="#FFFFFF"
        strokeWidth="2"
        strokeLinecap="round"
      />
    </motion.svg>
  );

  return (
    // The row reserves the resting width for the whole sequence, so the
    // form below the button never shifts while the request runs.
    <div
      style={{
        width: restWidth,
        height: HEIGHT,
        display: "flex",
        alignItems: "center",
        justifyContent: "center",
      }}
    >
      <motion.button
        type="button"
        disabled={phase !== "idle"}
        onClick={() => {
          if (phase !== "idle") return;
          setPhase("working");
          onSubmit?.();
        }}
        initial={false}
        animate={{
          width,
          // Hex to hex, so the color genuinely interpolates rather than
          // switching on a frame boundary.
          backgroundColor: phase === "done" ? DONE : ACCENT,
        }}
        transition={
          reduceMotion
            ? { duration: 0.18, ease: "easeOut" }
            : { ...cfg.spring, backgroundColor: { duration: 0.24, ease: "easeOut" } }
        }
        style={{
          position: "relative",
          height: HEIGHT,
          borderRadius: 12,
          border: 0,
          padding: 0,
          overflow: "hidden",
          color: "#FFFFFF",
          fontFamily: "inherit",
          cursor: phase === "idle" ? "pointer" : "default",
        }}
      >
        {/* Three layers stacked in the same box and crossfaded. Each one
            is centered and non-wrapping, so a label is never re-flowed by
            the width it is sitting inside — it fades, it does not reset. */}
        <motion.span
          initial={false}
          animate={{ opacity: phase === "idle" ? 1 : 0 }}
          transition={swap}
          style={layerStyle}
        >
          <span style={{ fontSize: 14, fontWeight: 600, whiteSpace: "nowrap" }}>
            {label}
          </span>
        </motion.span>

        <motion.span
          initial={false}
          animate={{ opacity: phase === "working" ? 1 : 0 }}
          transition={swap}
          style={layerStyle}
        >
          {ring}
          {reduceMotion ? (
            <span
              style={{
                fontSize: 14,
                fontWeight: 600,
                marginLeft: 9,
                whiteSpace: "nowrap",
              }}
            >
              {workingLabel}
            </span>
          ) : null}
        </motion.span>

        <motion.span
          initial={false}
          animate={{ opacity: phase === "done" ? 1 : 0 }}
          transition={swap}
          style={layerStyle}
        >
          <svg width="17" height="17" viewBox="0 0 16 16" fill="none" aria-hidden>
            <motion.path
              d="M3.4 8.4 6.3 11.3 12.6 5"
              stroke="#FFFFFF"
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
              initial={false}
              animate={{ pathLength: phase === "done" ? 1 : 0 }}
              transition={
                reduceMotion
                  ? { duration: 0 }
                  : { duration: 0.26, ease: "easeOut", delay: 0.04 }
              }
            />
          </svg>
          <span
            style={{
              fontSize: 14,
              fontWeight: 600,
              marginLeft: 8,
              whiteSpace: "nowrap",
            }}
          >
            {doneLabel}
          </span>
        </motion.span>

        {/* One live region for the whole control: the visual layers are
            crossfades, and a screen reader should hear one state. */}
        <span
          role="status"
          aria-live="polite"
          style={{
            position: "absolute",
            width: 1,
            height: 1,
            overflow: "hidden",
            clip: "rect(0 0 0 0)",
            whiteSpace: "nowrap",
          }}
        >
          {phase === "working" ? workingLabel : phase === "done" ? doneLabel : ""}
        </span>
      </motion.button>
    </div>
  );
}

const layerStyle = {
  position: "absolute" as const,
  inset: 0,
  display: "flex",
  alignItems: "center",
  justifyContent: "center",
  pointerEvents: "none" as const,
};

About this pattern

Three states in one control, with the form behind it holding perfectly still. The button collapses to a disc for the length of the request and opens back out to report what happened, while the row around it keeps the resting width the whole time — so nothing under the button shifts and no one loses their place. The label, the disc and the confirmation are three stacked layers crossfading inside a single box: text is never re-flowed by the width it sits in, it just fades. Pressing is locked out until the control is back at rest, which is the cheapest double-submit guard there is.

Create record submitCheckout pay buttonSend an invitePublish a change

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Issues
    Backlog
    Active
    Cycles
    Views
    IssuesNew
    Colourway picker drops a frameRID-412 · PriyaIn progress
    Receipt totals misalign on narrowRID-408 · MarcusTodo
    Session expires without warningRID-401 · DanaIn review
    Export queue stalls past 500 rowsRID-397 · NilsTodo
    Search ranks archived firstRID-390 · PriyaDone
    Issue tracker

    Submit controls resolve inline instead of throwing a blocking overlay.

Related patterns