Connection Restored
The offline bar turns green, confirms, and retracts in one continuous move.
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, 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Inbox
Offline strip confirms the reconnection before removing itself from the top of the list.
Related patterns
- Offline Banner DropA connection bar opens down out of the top edge, holds while the app retries, then retracts once it is back.
- Queue Position AdvanceYour place in line steps down, the figure swapping upward as the bar of people ahead shortens.
- Undo SnackbarA removed row leaves a bar behind, and a hairline drains across it toward the point of no return.