All patterns

Save and Continue

The button confirms the save on itself, then the finished section folds and the next opens.

formspremiumcalminteraction · finite · advanced · ~0.9s
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.

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

/**
 * Vibary · Save and Continue
 *
 * The commit step of a sectioned form. The button confirms the save on
 * itself first — a fill running across it and the label handing over —
 * and only then does the finished section fold down to a summary line
 * while the next one opens and its fields arrive.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are
 * mixed from the inherited text color; the accent and success hues are
 * literal because they carry meaning.
 * Works with zero props; tune via `variant`, `sections`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type FormField = {
  /** Field label. */
  label: string;
  /** Starting value. */
  value: string;
  /** Native input type. */
  type?: string;
};

export type FormSection = {
  /** Stable key. */
  id: string;
  /** Section heading. */
  title: string;
  /** Fields inside it. */
  fields: FormField[];
};

export type SaveAndContinueProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Sections, in the order they are completed. */
  sections?: FormSection[];
  /** Primary button colour. */
  accent?: string;
  /** Fires with the id of the section just saved. */
  onSectionSaved?: (id: string) => void;
};

type VariantConfig = {
  /** Seconds the confirmation fill takes to cross the button. */
  fillSeconds: number;
  /** ms the confirmed button is held before the sections trade places. */
  handoverMs: number;
  /** Seconds a section takes to fold or open. */
  foldSeconds: number;
  /** Seconds between one field of the new section arriving and the next. */
  fieldStagger: number;
  /** px a field travels in from. */
  fieldTravel: number;
};

// Quality rule: nothing springs. Heights are eased because a section
// that overshoots pushes everything below it and pulls it back, and the
// labels are text — they fade and travel a few pixels and keep one size
// throughout.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Brisk. For a long form someone is working through.
  subtle: {
    fillSeconds: 0.22,
    handoverMs: 340,
    foldSeconds: 0.26,
    fieldStagger: 0.03,
    fieldTravel: 4,
  },
  // The confirmation is read before the fold begins. The all-purpose
  // setting.
  default: {
    fillSeconds: 0.32,
    handoverMs: 520,
    foldSeconds: 0.36,
    fieldStagger: 0.05,
    fieldTravel: 7,
  },
  // Slower and more ceremonial, for a short high-stakes checkout.
  playful: {
    fillSeconds: 0.44,
    handoverMs: 700,
    foldSeconds: 0.48,
    fieldStagger: 0.075,
    fieldTravel: 11,
  },
};

/** Theme-adaptive neutral: `currentColor` is the text color this
 *  component inherits — near-black on a light page, near-white on a dark
 *  one — so mixing it with `transparent` yields a surface, border or fill
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const SUCCESS = "#2E9E6B";

const DEFAULT_SECTIONS: FormSection[] = [
  {
    id: "contact",
    title: "Contact details",
    fields: [
      { label: "Full name", value: "Rowan Ellis", type: "text" },
      { label: "Email", value: "rowan@meridianlabs.com", type: "email" },
    ],
  },
  {
    id: "delivery",
    title: "Delivery address",
    fields: [
      { label: "Street", value: "14 Harbour Lane", type: "text" },
      { label: "City", value: "Bristol", type: "text" },
      { label: "Postcode", value: "BS1 4TR", type: "text" },
    ],
  },
  {
    id: "payment",
    title: "Payment",
    fields: [
      { label: "Card on file", value: "Visa ending 6411", type: "text" },
    ],
  },
];

export default function SaveAndContinue({
  variant = "default",
  sections = DEFAULT_SECTIONS,
  accent = "#5B5BD6",
  onSectionSaved,
}: SaveAndContinueProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const [openIndex, setOpenIndex] = useState(0);
  const [savedCount, setSavedCount] = useState(0);
  const [confirming, setConfirming] = useState(false);
  const [ring, setRing] = useState<string | null>(null);

  const bodyRefs = useRef<(HTMLDivElement | null)[]>([]);
  const [heights, setHeights] = useState<number[]>([]);

  // Each body keeps its natural height even while its wrapper is folded
  // shut, so one pass before paint is enough to know every target.
  useLayoutEffect(() => {
    setHeights(sections.map((_, index) => bodyRefs.current[index]?.offsetHeight ?? 0));
  }, [sections, variant]);

  const save = (index: number) => {
    if (confirming) return;
    setConfirming(true);
    onSectionSaved?.(sections[index]?.id ?? "");
    // The button finishes saying "saved" before the sections trade
    // places: two events in sequence read as cause and effect, two
    // events at once read as a glitch.
    window.setTimeout(
      () => {
        setSavedCount((count) => Math.max(count, index + 1));
        setOpenIndex(index + 1 < sections.length ? index + 1 : -1);
        setConfirming(false);
      },
      reduceMotion ? 0 : cfg.handoverMs
    );
  };

  return (
    <div
      style={{
        width: 320,
        display: "flex",
        flexDirection: "column",
        gap: 8,
        color: "inherit",
      }}
    >
      {sections.map((section, index) => {
        const open = index === openIndex;
        const done = index < savedCount;
        const measured = heights.length > 0;

        return (
          <div
            key={section.id}
            style={{
              borderRadius: 12,
              border: `1px solid ${open ? tone(16) : tone(11)}`,
              background: tone(open ? 6 : 4),
              overflow: "hidden",
            }}
          >
            <div
              style={{
                display: "flex",
                alignItems: "center",
                gap: 9,
                padding: "11px 13px",
              }}
            >
              <span
                aria-hidden
                style={{
                  display: "grid",
                  placeItems: "center",
                  width: 19,
                  height: 19,
                  flex: "0 0 auto",
                  borderRadius: "50%",
                  fontSize: 10,
                  fontWeight: 700,
                  color: done ? "#fff" : "inherit",
                  opacity: done || open ? 1 : 0.45,
                  background: done ? SUCCESS : tone(10),
                  border: `1px solid ${done ? SUCCESS : tone(14)}`,
                }}
              >
                {index + 1}
              </span>

              <span style={{ fontSize: 12.5, fontWeight: 650, opacity: open ? 1 : 0.7 }}>
                {section.title}
              </span>

              {/* The folded section keeps its first answer on the header
                  line, so collapsing never hides what was entered. */}
              <motion.span
                initial={false}
                animate={{ opacity: done && !open ? 0.5 : 0 }}
                transition={{ duration: reduceMotion ? 0 : 0.22, ease: "easeOut" }}
                style={{
                  marginLeft: "auto",
                  fontSize: 11,
                  whiteSpace: "nowrap",
                  overflow: "hidden",
                  textOverflow: "ellipsis",
                  maxWidth: 120,
                }}
              >
                {section.fields[0]?.value}
              </motion.span>

              {done && !open && (
                <button
                  type="button"
                  onClick={() => setOpenIndex(index)}
                  onFocus={(event) =>
                    setRing(
                      event.currentTarget.matches(":focus-visible")
                        ? `edit-${section.id}`
                        : null
                    )
                  }
                  onBlur={() => setRing(null)}
                  style={{
                    flex: "0 0 auto",
                    padding: "3px 9px",
                    fontFamily: "inherit",
                    fontSize: 11,
                    fontWeight: 620,
                    color: "inherit",
                    background: "transparent",
                    border: `1px solid ${tone(13)}`,
                    borderRadius: 7,
                    cursor: "pointer",
                    boxShadow:
                      ring === `edit-${section.id}` ? `0 0 0 3px ${tone(20)}` : "none",
                    outline: "none",
                  }}
                >
                  Edit
                </button>
              )}
            </div>

            {/*
              Height is the only non-transform property animated here,
              and it is animated because the fold genuinely is a size
              change. Motion owns the value outright — `auto` is used
              only for the one render before the bodies are measured,
              and `initial={false}` means that first state is applied
              rather than animated to, so nothing ever flashes open.
            */}
            <motion.div
              initial={false}
              animate={{
                height: open ? (measured ? heights[index] : "auto") : 0,
              }}
              transition={{
                duration: reduceMotion ? 0 : cfg.foldSeconds,
                ease: [0.22, 0.61, 0.36, 1],
              }}
              style={{ overflow: "hidden" }}
              aria-hidden={!open}
            >
              <div ref={(node) => { bodyRefs.current[index] = node; }}>
                <div style={{ padding: "0 13px 13px" }}>
                  {section.fields.map((field, fieldIndex) => (
                    <motion.div
                      key={field.label}
                      initial={false}
                      animate={{
                        opacity: open ? 1 : 0,
                        y: open || reduceMotion ? 0 : cfg.fieldTravel,
                      }}
                      transition={{
                        duration: reduceMotion ? 0 : 0.24,
                        delay:
                          open && !reduceMotion ? fieldIndex * cfg.fieldStagger : 0,
                        ease: "easeOut",
                      }}
                      style={{ marginTop: fieldIndex === 0 ? 0 : 9 }}
                    >
                      <label
                        style={{
                          display: "block",
                          marginBottom: 4,
                          fontSize: 10.5,
                          fontWeight: 650,
                          letterSpacing: 0.2,
                          opacity: 0.5,
                        }}
                      >
                        {field.label}
                        <input
                          type={field.type ?? "text"}
                          defaultValue={field.value}
                          tabIndex={open ? 0 : -1}
                          style={{
                            display: "block",
                            width: "100%",
                            boxSizing: "border-box",
                            marginTop: 4,
                            padding: "7px 10px",
                            fontSize: 12.5,
                            fontWeight: 400,
                            letterSpacing: 0,
                            fontFamily: "inherit",
                            color: "inherit",
                            background: tone(7),
                            border: `1px solid ${tone(13)}`,
                            borderRadius: 8,
                            outline: "none",
                          }}
                        />
                      </label>
                    </motion.div>
                  ))}

                  <button
                    type="button"
                    onClick={() => save(index)}
                    disabled={!open || confirming}
                    tabIndex={open ? 0 : -1}
                    onFocus={(event) =>
                      setRing(
                        event.currentTarget.matches(":focus-visible")
                          ? `save-${section.id}`
                          : null
                      )
                    }
                    onBlur={() => setRing(null)}
                    style={{
                      position: "relative",
                      display: "block",
                      width: "100%",
                      marginTop: 12,
                      padding: "9px 14px",
                      fontFamily: "inherit",
                      fontSize: 12.5,
                      fontWeight: 700,
                      color: "#fff",
                      background: accent,
                      border: "none",
                      borderRadius: 9,
                      cursor: confirming ? "default" : "pointer",
                      overflow: "hidden",
                      boxShadow:
                        ring === `save-${section.id}` ? `0 0 0 3px ${tone(28)}` : "none",
                      outline: "none",
                    }}
                  >
                    {/* The confirmation runs across the button itself: a
                        fill from the leading edge, then the label hands
                        over. The button reports the save; the fold
                        reports what happens next. */}
                    <motion.span
                      aria-hidden
                      initial={false}
                      animate={{ scaleX: confirming && open ? 1 : 0 }}
                      transition={{
                        duration: reduceMotion ? 0 : cfg.fillSeconds,
                        ease: "easeOut",
                      }}
                      style={{
                        position: "absolute",
                        inset: 0,
                        background: SUCCESS,
                        transformOrigin: "left",
                      }}
                    />
                    <motion.span
                      initial={false}
                      animate={{ opacity: confirming && open ? 0 : 1 }}
                      transition={{ duration: reduceMotion ? 0 : 0.14 }}
                      style={{ position: "relative", display: "block" }}
                    >
                      {index + 1 < sections.length
                        ? "Save and continue"
                        : "Save and finish"}
                    </motion.span>
                    <motion.span
                      initial={false}
                      animate={{ opacity: confirming && open ? 1 : 0 }}
                      transition={{
                        duration: reduceMotion ? 0 : 0.16,
                        delay: confirming && !reduceMotion ? cfg.fillSeconds * 0.5 : 0,
                      }}
                      style={{
                        position: "absolute",
                        inset: 0,
                        display: "grid",
                        placeItems: "center",
                      }}
                    >
                      Saved
                    </motion.span>
                  </button>
                </div>
              </div>
            </motion.div>
          </div>
        );
      })}

      <div role="status" style={{ fontSize: 11, opacity: 0.45, marginTop: 2 }}>
        {savedCount} of {sections.length} sections saved
      </div>
    </div>
  );
}

About this pattern

The commit moment in a sectioned form, staged as two events rather than one. A fill runs across the button from its leading edge and the label hands over to a confirmation — the button reports the save — and only after that does the completed section fold down to a summary line while the following one opens and its fields arrive a beat apart. Folding never hides the answer: the first value stays on the header line with an edit control beside it. Heights are eased, not sprung; a section that overshoots pushes the whole page down and pulls it back.

Checkout section completedMulti-part account setupAddress then payment flowSave a form section before continuing

Where it shows up

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

  • 10:15
    Payment
    Card number4242 4242 4242 4242Name on cardN. Bergström
    Expiry04 / 28CVC•••
    Subtotal$156.00Shipping$0.00Tax$13.65Total$169.65
    Pay $169.65
    Checkout

    Completed sections collapse to a summary row with the next part opening below.

Related patterns