All patterns

Offline Banner Drop

A connection bar opens down out of the top edge, holds while the app retries, then retracts once it is back.

feedbackcalmminimalautomatic · finite · intermediate · ~4.6s
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.

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

/**
 * Vibary · Offline Banner Drop
 *
 * The connection bar: it opens down out of the top edge when the
 * network goes, holds while the app keeps trying, turns over to a
 * restored state, and only then retracts.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Works with zero props; tune via `variant`, `online`, `outageMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type OfflineBannerDropProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /**
   * Drive from your own connection state: false while the connection is
   * gone, true when it returns. Omit to run the sample outage. One bar
   * covers one outage — mount a fresh one for the next.
   */
  online?: boolean;
  /** Line shown while the connection is gone. */
  offlineLabel?: string;
  /** Line shown once it is back. */
  onlineLabel?: string;
  /** How long the sample outage lasts, in ms. */
  outageMs?: number;
  /** How long the restored line is held before the bar retracts, in ms. */
  restoredHoldMs?: number;
  /** Height of the bar, in px. */
  height?: number;
  /** Fires once the bar has finished retracting. */
  onRetract?: () => void;
};

type Phase = "offline" | "restored" | "gone";

type VariantConfig = {
  /** How far the bar's contents lead the opening height. */
  travel: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** How long the strip takes to open its height. */
  openDuration: number;
};

// The bar pushes the whole app down, so it must arrive without a rebound:
// a connection warning that bounces reads as a toy. Every ratio here is
// at or above 0.8, and variants differ only in travel and tempo.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  subtle: {
    travel: 8,
    spring: { type: "spring", stiffness: 480, damping: 42 },
    openDuration: 0.2,
  },
  default: {
    travel: 16,
    spring: { type: "spring", stiffness: 360, damping: 33 },
    openDuration: 0.26,
  },
  // A deeper drop: the bar clearly comes from above the viewport.
  playful: {
    travel: 26,
    spring: { type: "spring", stiffness: 320, damping: 29 },
    openDuration: 0.3,
  },
};

const OFFLINE = "#B45309";
const RESTORED = "#047857";

/** Theme-adaptive neutral: inside the bar the inherited color is the bar
 *  text, so this mixes correctly against either state's fill. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function OfflineBannerDrop({
  variant = "default",
  online,
  offlineLabel = "No connection. Retrying",
  onlineLabel = "Back online",
  outageMs = 2600,
  restoredHoldMs = 1500,
  height = 40,
  onRetract,
}: OfflineBannerDropProps) {
  const [autoRestored, setAutoRestored] = useState(false);
  const [retracted, setRetracted] = useState(false);
  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 outage.
  const onRetractRef = useRef(onRetract);
  useEffect(() => {
    onRetractRef.current = onRetract;
  }, [onRetract]);

  // The connection state is read, never mirrored into state: when the
  // host supplies `online` it is the source of truth on every render,
  // and only the two timers ever write.
  const controlled = online !== undefined;
  const restored = controlled ? online === true : autoRestored;
  const phase: Phase = retracted ? "gone" : restored ? "restored" : "offline";

  useEffect(() => {
    if (controlled) return;
    const timer = setTimeout(() => setAutoRestored(true), outageMs);
    return () => clearTimeout(timer);
  }, [controlled, outageMs]);

  useEffect(() => {
    if (!restored) return;
    const timer = setTimeout(() => {
      setRetracted(true);
      onRetractRef.current?.();
    }, restoredHoldMs);
    return () => clearTimeout(timer);
  }, [restored, restoredHoldMs]);

  // Reduced motion: the bar still takes its space and still turns over,
  // it just does not travel to get there.
  const travel = reduceMotion ? 0 : cfg.travel;

  return (
    <AnimatePresence>
      {phase !== "gone" && (
        <motion.div
          key="bar"
          // The height is the layout half of the motion: it is what
          // pushes the app down and lets it back up again.
          initial={{ height: 0 }}
          animate={{ height }}
          exit={{ height: 0 }}
          transition={{
            duration: reduceMotion ? 0.14 : cfg.openDuration,
            ease: "easeOut",
          }}
          style={{ overflow: "hidden", width: "100%" }}
        >
          <motion.div
            role="status"
            aria-live="polite"
            initial={{ y: -travel, opacity: 0 }}
            animate={{
              y: 0,
              opacity: 1,
              // Hex to hex, so the turn from warning to restored is a real
              // interpolation rather than a switch on one frame.
              backgroundColor: restored ? RESTORED : OFFLINE,
            }}
            exit={{
              y: -travel,
              opacity: 0,
              transition: { duration: 0.18, ease: "easeIn" },
            }}
            transition={
              reduceMotion
                ? { duration: 0.16, ease: "easeOut" }
                : {
                    ...cfg.spring,
                    opacity: { duration: 0.16, ease: "easeOut" },
                    backgroundColor: { duration: 0.3, ease: "easeOut" },
                  }
            }
            style={{
              position: "relative",
              height,
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              gap: 8,
              overflow: "hidden",
              color: "#FFFFFF",
              backgroundColor: OFFLINE,
            }}
          >
            {/* Both states share one grid cell, so the bar reserves the
                wider line and the turn cannot shift the sentence. */}
            <span style={{ display: "grid", placeItems: "center" }}>
              <motion.span
                style={{ gridArea: "1 / 1", display: "flex", alignItems: "center", gap: 8 }}
                initial={false}
                animate={{ opacity: restored ? 0 : 1 }}
                transition={{ duration: 0.18, ease: "easeOut" }}
              >
                <svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden>
                  <path
                    d="M1.6 5.6A9 9 0 0 1 14.4 5.6M4.3 8.4a5.2 5.2 0 0 1 7.4 0M8 12.4h.01"
                    stroke="currentColor"
                    strokeWidth="1.6"
                    strokeLinecap="round"
                  />
                  <path d="M2.6 13.4 13.4 2.6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
                </svg>
                <span style={{ fontSize: 13, fontWeight: 550, whiteSpace: "nowrap" }}>
                  {offlineLabel}
                </span>
              </motion.span>

              <motion.span
                aria-hidden={!restored}
                style={{ gridArea: "1 / 1", display: "flex", alignItems: "center", gap: 8 }}
                initial={false}
                animate={{ opacity: restored ? 1 : 0 }}
                transition={{ duration: 0.18, ease: "easeOut" }}
              >
                <svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden>
                  <path
                    d="M3.4 8.4 6.3 11.3 12.6 5"
                    stroke="currentColor"
                    strokeWidth="1.9"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  />
                </svg>
                <span style={{ fontSize: 13, fontWeight: 550, whiteSpace: "nowrap" }}>
                  {onlineLabel}
                </span>
              </motion.span>
            </span>

            {/* The retry, drawn as a band crossing the bottom edge. It is
                the only looping part of the pattern and it stops the
                moment the connection is back — or never starts, if the
                reader has asked for less movement. */}
            {!reduceMotion && !restored ? (
              <motion.span
                aria-hidden
                initial={{ x: "-16%" }}
                animate={{ x: "246%" }}
                transition={{ duration: 1.5, ease: "linear", repeat: Infinity }}
                style={{
                  position: "absolute",
                  left: 0,
                  bottom: 0,
                  width: "30%",
                  height: 2,
                  borderRadius: 2,
                  background: tone(55),
                }}
              />
            ) : null}
          </motion.div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

About this pattern

Losing the network is not an event to fire and forget: the notice has to stay for as long as the condition does. The strip opens its height from the top edge while its contents lead the drop by a few pixels, so it reads as arriving from above the viewport rather than expanding out of nothing. A band crossing the bottom edge carries the retrying — the one looping part of the pattern, and it stops the instant the connection returns. The bar then turns over to a restored line in place, holds it long enough to be read, and only then gives the space back. Nothing rebounds: a connection warning that bounces reads as a toy.

Network lostReconnecting noticeDegraded service warningSync paused

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

    Connection state reported as a thin strip rather than a blocking dialog.

Related patterns