All patterns

Connection Restored

The offline bar turns green, confirms, and retracts in one continuous move.

feedbackfriendlycalmautomatic · finite · intermediate · ~2.8s
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.

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

/**
 * Vibary · Connection Restored
 *
 * The offline bar earning its exit: it warms to green, says so, and
 * retracts — collapsing and sliding on the same curve, so the whole
 * thing reads as one move rather than three events.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The tints are literal because they are semantic; everything neutral
 * is mixed from the inherited text color.
 * Works with zero props; tune via `variant`, the labels, `offlineMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type ConnectionRestoredProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Drive it from your own connectivity state, or let it run. */
  online?: boolean | "auto";
  offlineLabel?: string;
  onlineLabel?: string;
  /** In "auto": how long the bar stays offline before the network returns. */
  offlineMs?: number;
  /** How long the confirmation holds before the bar retracts. */
  holdMs?: number;
  /** Bar height in px — also the distance it travels as it leaves. */
  barHeight?: number;
  /** Fires once the bar has finished retracting. */
  onRetract?: () => void;
};

type VariantConfig = {
  /** How long the surface takes to travel from warning to confirmation. */
  tintSeconds: number;
  /** The collapse and the slide, on one shared curve. */
  retractSeconds: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Damping ratios (damping / 2√stiffness) stay at or above 0.8. A bar that
// bounced on its way out would invite a second look at something the
// reader is finally allowed to stop thinking about.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Quick tint, quick exit. For an app where the network flaps often.
  subtle: {
    tintSeconds: 0.22,
    retractSeconds: 0.24,
    spring: { type: "spring", stiffness: 560, damping: 48 },
  },
  // Long enough to see the color change land. The all-purpose setting.
  default: {
    tintSeconds: 0.3,
    retractSeconds: 0.32,
    spring: { type: "spring", stiffness: 440, damping: 38 },
  },
  // A slower, more deliberate recovery for a full-width app chrome bar.
  playful: {
    tintSeconds: 0.36,
    retractSeconds: 0.4,
    spring: { type: "spring", stiffness: 360, damping: 31 },
  },
};

const WARN = "#E0A33E";
const DONE_COLOR = "#2FA36B";
/** Literal rgba rather than color-mix: these two states tween into each
 *  other, and a color-mix string has nothing to interpolate. */
const SKIN = {
  offline: {
    background: "rgba(224, 163, 62, 0.16)",
    border: "rgba(224, 163, 62, 0.32)",
  },
  online: {
    background: "rgba(47, 163, 107, 0.16)",
    border: "rgba(47, 163, 107, 0.32)",
  },
};

export default function ConnectionRestored({
  variant = "default",
  online = "auto",
  offlineLabel = "No connection — retrying",
  onlineLabel = "Back online",
  offlineMs = 1500,
  holdMs = 1000,
  barHeight = 36,
  onRetract,
}: ConnectionRestoredProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  // Controlled use reads the connection straight off the prop; only
  // "auto" keeps its own. Once the bar has retracted it stays retracted
  // — mount a fresh one (or change its key) for the next outage.
  const [autoRestored, setAutoRestored] = useState(false);
  const [gone, setGone] = useState(false);
  const restored = online === "auto" ? autoRestored : online === true;

  useEffect(() => {
    if (online !== "auto") return;
    const timer = setTimeout(() => setAutoRestored(true), offlineMs);
    return () => clearTimeout(timer);
  }, [online, offlineMs]);

  useEffect(() => {
    if (!restored) return;
    const timer = setTimeout(() => setGone(true), holdMs);
    return () => clearTimeout(timer);
  }, [restored, holdMs]);
  const skin = restored ? SKIN.online : SKIN.offline;
  const widest =
    onlineLabel.length >= offlineLabel.length ? onlineLabel : offlineLabel;

  // Collapse and slide share one duration and one curve. Split them and
  // the exit stops being a single gesture.
  const retract = {
    duration: reduceMotion ? 0.18 : cfg.retractSeconds,
    ease: [0.4, 0, 1, 1] as [number, number, number, number],
  };

  return (
    <motion.div
      initial={false}
      animate={{ height: gone ? 0 : barHeight }}
      transition={retract}
      style={{ width: "100%", overflow: "hidden" }}
      onAnimationComplete={() => {
        if (gone) onRetract?.();
      }}
    >
      <motion.div
        role="status"
        aria-live="polite"
        initial={false}
        animate={{
          y: gone && !reduceMotion ? -barHeight : 0,
          opacity: gone ? 0 : 1,
          backgroundColor: skin.background,
          borderColor: skin.border,
        }}
        transition={{
          y: retract,
          opacity: retract,
          backgroundColor: { duration: cfg.tintSeconds, ease: "easeOut" },
          borderColor: { duration: cfg.tintSeconds, ease: "easeOut" },
        }}
        style={{
          height: barHeight,
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          gap: 8,
          padding: "0 14px",
          borderRadius: 10,
          borderWidth: 1,
          borderStyle: "solid",
          fontSize: 12.5,
          fontWeight: 550,
        }}
      >
        <span
          aria-hidden
          style={{
            position: "relative",
            display: "inline-block",
            width: 14,
            height: 14,
            flexShrink: 0,
          }}
        >
          {/* Offline: a dot breathing on its own slow curve — the only
              honest way to say "still trying" without a percentage.
              Reduced motion leaves it lit and still. */}
          <motion.span
            initial={false}
            animate={
              restored
                ? { opacity: 0 }
                : reduceMotion
                  ? { opacity: 0.9 }
                  : { opacity: [0.35, 0.95] }
            }
            transition={
              restored || reduceMotion
                ? { duration: cfg.tintSeconds, ease: "easeOut" }
                : {
                    duration: 0.9,
                    repeat: Infinity,
                    repeatType: "mirror",
                    ease: "easeInOut",
                  }
            }
            style={{
              position: "absolute",
              left: 3,
              top: 3,
              width: 8,
              height: 8,
              borderRadius: "50%",
              background: WARN,
            }}
          />
          <motion.span
            initial={false}
            animate={{
              opacity: restored ? 1 : 0,
              scale: restored || reduceMotion ? 1 : 0.5,
            }}
            transition={
              reduceMotion
                ? { duration: cfg.tintSeconds, ease: "easeOut" }
                : {
                    ...cfg.spring,
                    opacity: { duration: cfg.tintSeconds, ease: "easeOut" },
                  }
            }
            style={{ position: "absolute", inset: 0, display: "block" }}
          >
            <svg width="14" height="14" viewBox="0 0 16 16" fill="none">
              <path
                d="M3.9 8.3 6.6 11 12.1 5.3"
                stroke={DONE_COLOR}
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
              />
            </svg>
          </motion.span>
        </span>

        {/* Both labels share one slot sized to the longer of them, so the
            bar's contents never shift while the words change. */}
        <span style={{ position: "relative", display: "inline-block" }}>
          <span aria-hidden style={{ visibility: "hidden", whiteSpace: "nowrap" }}>
            {widest}
          </span>
          {[
            { text: offlineLabel, active: !restored, color: WARN },
            { text: onlineLabel, active: restored, color: DONE_COLOR },
          ].map((entry) => (
            <motion.span
              key={entry.text}
              aria-hidden={!entry.active}
              initial={false}
              animate={{ opacity: entry.active ? 1 : 0 }}
              transition={{ duration: cfg.tintSeconds, ease: "easeOut" }}
              style={{
                position: "absolute",
                inset: 0,
                textAlign: "center",
                whiteSpace: "nowrap",
                color: entry.color,
                pointerEvents: "none",
              }}
            >
              {entry.text}
            </motion.span>
          ))}
        </span>
      </motion.div>
    </motion.div>
  );
}

About this pattern

Recovery, told as a single gesture. The bar tints from warning amber to confirmation green while the pulsing dot gives way to a tick and the label crossfades in a slot sized to its longer state. After a beat it leaves: the wrapper collapses and the bar slides up on the same duration and the same curve, so the exit reads as one movement rather than a shrink plus a slide, and the content underneath closes the gap without a jump. Reduced motion stills the dot and shortens the exit, but the color and the words still do the work.

Offline mode bannerNetwork recoveryRealtime socket reconnectSync resumed after a drop

Where it shows up

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

  • Ridgeline
    Inbox
    Starred
    Drafts
    Archive
    Sent
    InboxNew
    Contract renewalPriya Raman · 10:14
    Q3 hiring planMarcus Bell · 09:02
    Venue confirmed for ThursdayDana Whitfield · Tue
    Invoice 4821 clearedBilling · Tue
    Weekly summaryReports · Mon
    Inbox

    Offline strip confirms the reconnection before removing itself from the top of the list.

Related patterns