All patterns

Sync Status Rotate

A sync glyph turns slowly while changes upload, then stops on a tick.

feedbacksubtlecalmautomatic · looping · starter · ~4.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.

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

/**
 * Vibary · Sync Status Rotate
 *
 * The saving indicator in a document header: a two-arc glyph turns
 * slowly while changes upload, then stops on a tick as the label
 * crossfades to "All changes saved".
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The chip is mixed from the inherited text color, so it reads correctly
 * on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `status`, the labels.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SyncStatusRotateProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /**
   * Drive it from your own sync state. "auto" cycles the two phases,
   * which is what a live document actually does all day.
   */
  status?: "auto" | "syncing" | "synced";
  syncingLabel?: string;
  syncedLabel?: string;
  /** In "auto": how long each phase holds, in ms. */
  syncingMs?: number;
  syncedMs?: number;
};

type VariantConfig = {
  /** Seconds per full turn while syncing. */
  spinSeconds: number;
  /** How the tick lands once the turning stops. */
  spring: { type: "spring"; stiffness: number; damping: number };
  /** Crossfade between the two glyphs and the two labels. */
  swapSeconds: number;
};

// Damping ratios (damping / 2√stiffness) all sit at or above 0.8. This
// indicator lives in a toolbar the reader glances at mid-sentence, so
// the tick has to arrive settled — variants change pace, never bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Slow turn, flat landing. For a chrome slot you should barely notice.
  subtle: {
    spinSeconds: 2.4,
    spring: { type: "spring", stiffness: 520, damping: 46 },
    swapSeconds: 0.16,
  },
  // Readable turn, one soft settle. The all-purpose setting.
  default: {
    spinSeconds: 1.8,
    spring: { type: "spring", stiffness: 440, damping: 38 },
    swapSeconds: 0.18,
  },
  // Quicker turn for a sync people are actively waiting on.
  playful: {
    spinSeconds: 1.2,
    spring: { type: "spring", stiffness: 380, damping: 32 },
    swapSeconds: 0.2,
  },
};

const DONE_COLOR = "#2FA36B";

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

export default function SyncStatusRotate({
  variant = "default",
  status = "auto",
  syncingLabel = "Saving changes",
  syncedLabel = "All changes saved",
  syncingMs = 2400,
  syncedMs = 1800,
}: SyncStatusRotateProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  // Controlled use reads straight off the prop; only "auto" keeps state,
  // so there is nothing to synchronize back and forth.
  const [autoPhase, setAutoPhase] = useState<"syncing" | "synced">("syncing");
  const phase = status === "auto" ? autoPhase : status;

  useEffect(() => {
    if (status !== "auto") return;
    const timer = setTimeout(
      () =>
        setAutoPhase((current) => (current === "syncing" ? "synced" : "syncing")),
      phase === "syncing" ? syncingMs : syncedMs
    );
    return () => clearTimeout(timer);
  }, [status, phase, syncingMs, syncedMs]);

  const done = phase === "synced";
  // The wider label reserves the chip's width, so the crossfade never
  // resizes the toolbar under the reader's eye.
  const widest =
    syncedLabel.length >= syncingLabel.length ? syncedLabel : syncingLabel;

  return (
    <div
      role="status"
      aria-live="polite"
      style={{
        display: "inline-flex",
        alignItems: "center",
        gap: 8,
        padding: "6px 12px 6px 10px",
        borderRadius: 999,
        background: tone(6),
        border: `1px solid ${tone(12)}`,
        fontSize: 12.5,
      }}
    >
      <span
        aria-hidden
        style={{
          position: "relative",
          width: 16,
          height: 16,
          flexShrink: 0,
        }}
      >
        <AnimatePresence initial={false}>
          {done ? (
            <motion.span
              key="done"
              initial={
                reduceMotion
                  ? { opacity: 0 }
                  : { opacity: 0, scale: 0.55, rotate: -18 }
              }
              animate={{ opacity: 1, scale: 1, rotate: 0 }}
              exit={{
                opacity: 0,
                transition: { duration: cfg.swapSeconds, ease: "easeOut" },
              }}
              transition={
                reduceMotion
                  ? { duration: cfg.swapSeconds, ease: "easeOut" }
                  : {
                      ...cfg.spring,
                      opacity: { duration: cfg.swapSeconds, ease: "easeOut" },
                    }
              }
              style={{ position: "absolute", inset: 0, display: "block" }}
            >
              <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
                <path
                  d="M4.2 8.4 6.9 11.1 11.8 5.5"
                  stroke={DONE_COLOR}
                  strokeWidth="1.9"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                />
              </svg>
            </motion.span>
          ) : (
            // Two nested elements on purpose: the outer one owns arrival
            // and departure, the inner one owns the endless turn. Leaving
            // on a decelerating curve is what makes the pair read as one
            // glyph coming to rest rather than two icons swapping.
            <motion.span
              key="syncing"
              initial={{ opacity: 0 }}
              animate={{ opacity: 0.55 }}
              exit={{
                opacity: 0,
                scale: reduceMotion ? 1 : 0.72,
                transition: { duration: cfg.swapSeconds, ease: "easeOut" },
              }}
              transition={{ duration: cfg.swapSeconds, ease: "easeOut" }}
              style={{ position: "absolute", inset: 0, display: "block" }}
            >
              <motion.span
                // Reduced motion: the glyph stays put. The label and the
                // tick still carry the state, so nothing is lost.
                animate={reduceMotion ? undefined : { rotate: 360 }}
                transition={{
                  duration: cfg.spinSeconds,
                  repeat: Infinity,
                  ease: "linear",
                }}
                style={{ display: "block", width: 16, height: 16 }}
              >
                <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
                  <path
                    d="M2.7 7.1A5.4 5.4 0 0 1 13.3 7.1"
                    stroke="currentColor"
                    strokeWidth="1.7"
                    strokeLinecap="round"
                  />
                  <path
                    d="M12.7 10.7A5.4 5.4 0 0 1 3.3 10.7"
                    stroke="currentColor"
                    strokeWidth="1.7"
                    strokeLinecap="round"
                  />
                </svg>
              </motion.span>
            </motion.span>
          )}
        </AnimatePresence>
      </span>

      <span style={{ position: "relative", display: "inline-block" }}>
        {/* Invisible sizer: the chip is as wide as its longest state, so
            no label change can nudge the toolbar. */}
        <span aria-hidden style={{ visibility: "hidden", whiteSpace: "nowrap" }}>
          {widest}
        </span>
        {[
          { text: syncingLabel, active: !done },
          { text: syncedLabel, active: done },
        ].map((entry) => (
          <motion.span
            key={entry.text}
            aria-hidden={!entry.active}
            initial={false}
            animate={{ opacity: entry.active ? (done ? 0.85 : 0.6) : 0 }}
            transition={{ duration: cfg.swapSeconds, ease: "easeOut" }}
            style={{
              position: "absolute",
              inset: 0,
              whiteSpace: "nowrap",
              pointerEvents: "none",
            }}
          >
            {entry.text}
          </motion.span>
        ))}
      </span>
    </div>
  );
}

About this pattern

The saving indicator that lives in a document header. While changes are in flight a two-arc glyph turns on a linear loop — no easing, because an eased turn implies a progress it does not know. When the upload lands the glyph leaves on a decelerating curve as the tick springs in, so the pair reads as one mark coming to rest rather than two icons swapping. The label crossfades inside a slot sized to its longest state, so the toolbar never shifts while someone is mid-sentence.

Document autosaveOffline queue uploadMailbox syncWorkspace settings sync

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

    Header status swaps between saving and saved without moving the title.

Related patterns