All patterns

Changes Saved Pill

A pill drifts up from the toolbar to confirm an autosave, then dissolves.

feedbacksubtleelegantautomatic · finite · starter · ~2.4s
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.

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

/**
 * Vibary · Changes Saved Pill
 *
 * The receipt for an autosave: a small pill drifts up out of the
 * toolbar, rests just long enough to be read, then keeps drifting as
 * it dissolves.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The pill follows the host app's color scheme, so it lands light on a
 * light page and dark on a dark one.
 * Works with zero props; tune via `variant`, `label`, `holdMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ChangesSavedPillProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  label?: string;
  /** Quiet second half of the line — pass an empty string to drop it. */
  meta?: string;
  /** How long the pill rests before it leaves, in ms. */
  holdMs?: number;
  /** Fires once the pill has finished leaving. */
  onDismiss?: () => void;
};

type VariantConfig = {
  /** px the pill rises from as it arrives. */
  rise: number;
  /** px it keeps travelling as it dissolves. */
  drift: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  exitSeconds: number;
};

// Damping ratios (damping / 2√stiffness) stay at or above 0.8. This pill
// confirms something the reader did not ask about, so it must not bob
// for attention. Variants differ in travel and pace, never in bounce.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A few pixels of drift. For an editor that saves every keystroke.
  subtle: {
    rise: 4,
    drift: 3,
    spring: { type: "spring", stiffness: 570, damping: 48 },
    exitSeconds: 0.2,
  },
  // Enough drift to register in peripheral vision. All-purpose.
  default: {
    rise: 10,
    drift: 7,
    spring: { type: "spring", stiffness: 420, damping: 38 },
    exitSeconds: 0.26,
  },
  // A longer arc for a save the reader has been waiting on.
  playful: {
    rise: 16,
    drift: 11,
    spring: { type: "spring", stiffness: 340, damping: 31 },
    exitSeconds: 0.32,
  },
};

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 ChangesSavedPill({
  variant = "default",
  label = "Changes saved",
  meta = "just now",
  holdMs = 1800,
  onDismiss,
}: ChangesSavedPillProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [open, setOpen] = useState(true);

  useEffect(() => {
    const timer = setTimeout(() => setOpen(false), holdMs);
    return () => clearTimeout(timer);
  }, [holdMs]);

  // Reduced motion: the pill still arrives and still leaves, it just
  // does not travel to do it.
  const rise = reduceMotion ? 0 : cfg.rise;
  const drift = reduceMotion ? 0 : cfg.drift;

  return (
    <AnimatePresence onExitComplete={onDismiss}>
      {open && (
        <motion.div
          role="status"
          aria-live="polite"
          initial={{ opacity: 0, y: rise }}
          animate={{ opacity: 1, y: 0 }}
          exit={{
            opacity: 0,
            // It keeps going the way it came in, so the whole life of the
            // pill is one upward drift rather than an arrival and a retreat.
            y: -drift,
            transition: { duration: cfg.exitSeconds, ease: "easeIn" },
          }}
          transition={
            reduceMotion
              ? { duration: 0.18, ease: "easeOut" }
              : {
                  ...cfg.spring,
                  // Opacity on its own quick curve; springing it looks muddy.
                  opacity: { duration: 0.18, ease: "easeOut" },
                }
          }
          // Translate and opacity only. Scaling the pill would scale the
          // words inside it, which is the one thing text must never do.
          style={{
            display: "inline-flex",
            alignItems: "center",
            gap: 7,
            padding: "6px 12px 6px 10px",
            borderRadius: 999,
            fontSize: 12.5,
            // The pill sits over page content, so the surface is opaque.
            // `Canvas`/`CanvasText` are the CSS system colors for page
            // background and page text: they follow the host app's color
            // scheme, so the pill is light in a light app and dark in a
            // dark one. Everything inside then mixes from `currentColor`.
            background: "Canvas",
            color: "CanvasText",
            border: `1px solid ${tone(13)}`,
            boxShadow: "0 8px 22px rgba(0,0,0,0.16)",
            whiteSpace: "nowrap",
          }}
        >
          <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
            <circle
              cx="8"
              cy="8"
              r="6.5"
              stroke={DONE_COLOR}
              strokeWidth="1.5"
              opacity="0.5"
            />
            <path
              d="M5.2 8.2 7.1 10.1 10.9 6"
              stroke={DONE_COLOR}
              strokeWidth="1.8"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
          <span style={{ fontWeight: 550 }}>{label}</span>
          {meta ? <span style={{ opacity: 0.45 }}>{meta}</span> : null}
        </motion.div>
      )}
    </AnimatePresence>
  );
}

About this pattern

Confirmation for something the reader never asked to be told. The pill rises a few pixels out of the toolbar on a flat spring, rests exactly long enough to be read, and then keeps drifting the same direction as it fades — so its whole life is one upward move rather than an arrival followed by a retreat. It has no dismiss control and no timer, because a save is not a decision. The surface uses the CSS system colors, so it stays opaque and legible over the document in either color scheme.

Editor autosaveDraft committedSettings appliedInline field saved

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

    Short confirmations appear next to the control that caused them and leave without a dismiss button.

Related patterns