All patterns

Save Indicator Settle

A turning ring under "Saving" resolves into a drawn tick under "Saved", then the chip recedes.

feedbacksubtleminimalautomatic · finite · starter · ~3.2s
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.

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

/**
 * Vibary · Save Indicator Settle
 *
 * The autosave status line: a turning ring under "Saving" resolves
 * into a drawn tick under "Saved", and once the news has landed the
 * chip fades itself out of the toolbar.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Works with zero props; tune via `variant`, `savingMs`, `holdMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SaveIndicatorSettleProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /**
   * Drive the chip from your own save state. Omit to run the full
   * saving → saved → gone sequence. When you supply it you own when the
   * chip unmounts; `onSettled` is the cue.
   */
  status?: "saving" | "saved";
  /** How long the sample sequence spends saving, in ms. */
  savingMs?: number;
  /** How long "Saved" is held before the chip recedes, in ms. */
  holdMs?: number;
  /** Label while the write is in flight. */
  savingLabel?: string;
  /** Label once the write has landed. */
  savedLabel?: string;
  /** Fires once the chip has finished receding. */
  onSettled?: () => void;
};

type VariantConfig = {
  spinSeconds: number;
  swapDuration: number;
  tickDuration: number;
  /** How far the chip rises as it recedes. */
  lift: number;
};

// Nothing here springs: a save indicator is peripheral, and a spring in
// the corner of the eye reads as an interruption. Variants change the
// tempo of the crossfade and the length of the turn, never the character.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Slowest turn, softest swap — for editors that save constantly.
  subtle: { spinSeconds: 1.1, swapDuration: 0.2, tickDuration: 0.22, lift: 2 },
  default: { spinSeconds: 0.9, swapDuration: 0.16, tickDuration: 0.26, lift: 3 },
  // Quicker turn and a slightly longer rise on the way out.
  playful: { spinSeconds: 0.72, swapDuration: 0.14, tickDuration: 0.3, lift: 5 },
};

const SAVED = "#10B981";

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function SaveIndicatorSettle({
  variant = "default",
  status,
  savingMs = 1500,
  holdMs = 1400,
  savingLabel = "Saving",
  savedLabel = "Saved",
  onSettled,
}: SaveIndicatorSettleProps) {
  const [phase, setPhase] = useState<"saving" | "saved" | "gone">("saving");
  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 sequence part-way through.
  const onSettledRef = useRef(onSettled);
  useEffect(() => {
    onSettledRef.current = onSettled;
  }, [onSettled]);

  // Controlled by the host app when `status` is supplied; otherwise the
  // chip runs the full saving → saved → gone sequence by itself.
  const controlled = status !== undefined;

  useEffect(() => {
    if (controlled) return;
    const timer = setTimeout(() => setPhase("saved"), savingMs);
    return () => clearTimeout(timer);
  }, [controlled, savingMs]);

  const settled = controlled ? status === "saved" : phase === "saved";

  useEffect(() => {
    if (!settled) return;
    const timer = setTimeout(() => {
      setPhase("gone");
      onSettledRef.current?.();
    }, holdMs);
    return () => clearTimeout(timer);
  }, [settled, holdMs]);

  const visible = controlled ? true : phase !== "gone";

  return (
    <AnimatePresence>
      {visible && (
        <motion.div
          role="status"
          aria-live="polite"
          initial={false}
          exit={{
            opacity: 0,
            // The chip recedes upward by a few pixels. Text may translate;
            // it may never scale, so nothing here touches size.
            y: reduceMotion ? 0 : -cfg.lift,
            transition: { duration: 0.28, ease: "easeIn" },
          }}
          style={{
            display: "inline-flex",
            alignItems: "center",
            gap: 7,
            padding: "5px 10px 5px 8px",
            borderRadius: 999,
            background: tone(6),
            border: `1px solid ${tone(10)}`,
            fontSize: 12.5,
            lineHeight: 1,
          }}
        >
          {/* Ring and tick share one 14px cell so the chip cannot twitch
              sideways when one replaces the other. */}
          <span
            aria-hidden
            style={{ display: "grid", width: 14, height: 14, flexShrink: 0 }}
          >
            <motion.span
              style={{ gridArea: "1 / 1", display: "block", width: 14, height: 14 }}
              animate={{ opacity: settled ? 0 : 1 }}
              transition={{ duration: cfg.swapDuration, ease: "easeOut" }}
            >
              {/* Reduced motion: the ring stops turning and reads as a
                  static gauge — the label still says what is happening. */}
              <motion.svg
                width="14"
                height="14"
                viewBox="0 0 16 16"
                fill="none"
                animate={reduceMotion ? undefined : { rotate: 360 }}
                transition={{
                  duration: cfg.spinSeconds,
                  ease: "linear",
                  repeat: Infinity,
                }}
              >
                <circle
                  cx="8"
                  cy="8"
                  r="6"
                  stroke="currentColor"
                  strokeWidth="1.8"
                  opacity="0.2"
                />
                <path
                  d="M8 2a6 6 0 0 1 6 6"
                  stroke="currentColor"
                  strokeWidth="1.8"
                  strokeLinecap="round"
                  opacity="0.65"
                />
              </motion.svg>
            </motion.span>

            <motion.span
              style={{ gridArea: "1 / 1", display: "block", width: 14, height: 14 }}
              initial={false}
              animate={{ opacity: settled ? 1 : 0 }}
              transition={{ duration: cfg.swapDuration, ease: "easeOut" }}
            >
              <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
                <motion.path
                  d="M3.6 8.4 6.4 11.2 12.4 5.2"
                  stroke={SAVED}
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  initial={false}
                  animate={{ pathLength: settled ? 1 : 0 }}
                  transition={
                    reduceMotion
                      ? { duration: 0 }
                      : { duration: cfg.tickDuration, ease: "easeOut" }
                  }
                />
              </svg>
            </motion.span>
          </span>

          {/* Both labels live in one grid cell, so the chip reserves the
              wider of the two and the word can change without the chip
              resizing under it. */}
          <span style={{ display: "grid" }}>
            <motion.span
              style={{ gridArea: "1 / 1", opacity: 0.62, whiteSpace: "nowrap" }}
              animate={{ opacity: settled ? 0 : 0.62 }}
              transition={{ duration: cfg.swapDuration, ease: "easeOut" }}
            >
              {savingLabel}
            </motion.span>
            <motion.span
              aria-hidden={!settled}
              style={{ gridArea: "1 / 1", fontWeight: 550, whiteSpace: "nowrap" }}
              initial={false}
              animate={{ opacity: settled ? 1 : 0 }}
              transition={{ duration: cfg.swapDuration, ease: "easeOut" }}
            >
              {savedLabel}
            </motion.span>
          </span>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

About this pattern

Autosave has to be believable without being loud. The chip turns while the write is in flight, swaps the ring for a tick that strokes itself in, and once the news has landed it fades out of the toolbar instead of sitting there forever. Ring and tick share one 14px cell and both words share one grid cell, so the chip reserves the wider state up front and the swap cannot twitch the header sideways. Nothing springs and nothing scales: a status indicator lives in the corner of the eye, and a bounce there reads as an interruption.

Autosave in an editorSettings appliedDraft syncedInline edit committed

Where it shows up

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

  • Ridgeline
    Docs
    Recent
    Shared
    Templates
    Trash
    DocsNew
    Q3 planning notesEdited 14 minutes agoScope
    Document page

    The header reports the write in flight, then settles on a saved state.

Related patterns