Offline Banner Drop
A connection bar opens down out of the top edge, holds while the app retries, then retracts once it is back.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Inbox
Connection state reported as a thin strip rather than a blocking dialog.
Related patterns
- Connection RestoredThe offline bar turns green, confirms, and retracts in one continuous move.
- Password Strength BarA segmented meter fills bar by bar and shifts hue as a password improves.
- Session Expiry CountdownA session-timeout dialog counts down on a draining ring with a clear stay-signed-in action.