No Connection
Two halves of a broken link stand apart, then close the gap in one settle when the attempt lands.
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 · No Connection
*
* The illustration carries the state instead of decorating it: two halves
* of a link stand apart with the break marked between them, and when the
* attempt succeeds they close the gap and the break fades. One drawing,
* two readings — which is why the recovery needs no new artwork and no
* color change to be understood.
*
* Self-contained: depends only on `react` and `motion`. Neutrals are
* mixed from the inherited text color, so it reads on light and dark
* pages alike. Works with zero props; pass `connected` to drive it from
* your own connectivity state.
* Requires the automatic JSX runtime (default since React 17).
*/
export type NoConnectionIllustrationProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Drive this from your connectivity state. Left undefined, the
* component reconnects itself after `reconnectAfterMs`. */
connected?: boolean;
/** Only consulted while `connected` is undefined. */
reconnectAfterMs?: number;
/** How long an attempt takes before it lands. */
attemptMs?: number;
/** Headlines for each state. */
offlineTitle?: string;
onlineTitle?: string;
/** Supporting lines for each state. */
offlineMessage?: string;
onlineMessage?: string;
/** Button labels, at rest and mid-attempt. */
retryLabel?: string;
attemptingLabel?: string;
/** Fires when an attempt starts. */
onRetry?: () => void;
/** Block width — px number or any CSS length. */
width?: number | string;
};
type VariantConfig = {
/** px each half of the link sits away from centre while broken. */
gap: number;
/** px the copy travels on its way in. */
rise: number;
fadeSeconds: number;
/** The join: sprung, because two halves meeting is a physical event. */
joinSpring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the join is the only spring, above 0.9 damping ratio
// (damping / 2√stiffness) in every variant, so the link closes with one
// soft settle and no rattle. Text crossfades and never scales.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A small gap, for a strip inside a working page.
subtle: {
gap: 2,
rise: 4,
fadeSeconds: 0.22,
joinSpring: { type: "spring", stiffness: 570, damping: 48 },
},
// The all-purpose setting: the gap is obvious, the join is calm.
default: {
gap: 5,
rise: 8,
fadeSeconds: 0.3,
joinSpring: { type: "spring", stiffness: 420, damping: 40 },
},
// A wider break, for a full-screen disconnected state.
playful: {
gap: 9,
rise: 14,
fadeSeconds: 0.38,
joinSpring: { type: "spring", stiffness: 310, damping: 33 },
},
};
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` keeps the link, the break marks and the action correct
* on light and dark pages alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function NoConnectionIllustration({
variant = "default",
connected,
reconnectAfterMs = 2600,
attemptMs = 900,
offlineTitle = "No connection",
onlineTitle = "Back online",
offlineMessage = "We can't reach the server right now.",
onlineMessage = "Everything is syncing again.",
retryLabel = "Try again",
attemptingLabel = "Connecting",
onRetry,
width = 320,
}: NoConnectionIllustrationProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [selfConnected, setSelfConnected] = useState(false);
const [attempting, setAttempting] = useState(false);
// Uncontrolled by default so the file runs on its own; the moment a
// caller passes `connected`, these timers stay out of the way.
useEffect(() => {
if (connected !== undefined || selfConnected) return;
const timer = setTimeout(() => setAttempting(true), reconnectAfterMs);
return () => clearTimeout(timer);
}, [connected, selfConnected, reconnectAfterMs]);
useEffect(() => {
if (!attempting) return;
const timer = setTimeout(() => {
setSelfConnected(true);
setAttempting(false);
}, attemptMs);
return () => clearTimeout(timer);
}, [attempting, attemptMs]);
const online = connected ?? selfConnected;
const rise = reduceMotion ? 0 : cfg.rise;
const fade = { duration: cfg.fadeSeconds, ease: "easeOut" as const };
const gap = online ? 0 : cfg.gap;
const retry = () => {
if (online || attempting) return;
setAttempting(true);
onRetry?.();
};
return (
<div
style={{
position: "relative",
width,
boxSizing: "border-box",
display: "flex",
flexDirection: "column",
alignItems: "center",
textAlign: "center",
padding: "26px 22px 22px",
}}
>
<div aria-hidden style={{ lineHeight: 0, marginBottom: 14 }}>
<svg width="72" height="46" viewBox="0 0 72 46" fill="none">
{/* Left half. Both halves travel on the same spring, so they
meet in the middle rather than one chasing the other. */}
<motion.g
initial={false}
animate={{ x: -gap }}
transition={
reduceMotion ? { duration: 0 } : cfg.joinSpring
}
>
<path
d="M33 15h-8a8 8 0 0 0 0 16h8"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
opacity="0.36"
/>
</motion.g>
<motion.g
initial={false}
animate={{ x: gap }}
transition={
reduceMotion ? { duration: 0 } : cfg.joinSpring
}
>
<path
d="M39 15h8a8 8 0 0 1 0 16h-8"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
opacity="0.36"
/>
</motion.g>
{/* The bar across the middle exists only when the link holds. */}
<motion.path
d="M31 23h10"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
initial={false}
animate={{ opacity: online ? 0.36 : 0 }}
transition={fade}
/>
{/* Break marks: the two short strokes that make this a snapped
link rather than an unfinished one. */}
<motion.g
initial={false}
animate={{ opacity: online ? 0 : 0.26 }}
transition={fade}
>
<path
d="M36 6.5v4M30.5 8.5l2 3.2M41.5 8.5l-2 3.2"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</motion.g>
</svg>
</div>
{/* Both readings share one cell, so the block holds its height as
the connection comes back. */}
<span style={{ display: "grid", justifyItems: "center" }}>
<motion.span
initial={{ opacity: 0, y: rise }}
animate={{ opacity: online ? 0 : 1, y: 0 }}
transition={{ ...fade, delay: 0.06 }}
style={{ gridArea: "1 / 1", fontSize: 15, fontWeight: 640 }}
>
{offlineTitle}
</motion.span>
<motion.span
initial={false}
animate={{ opacity: online ? 1 : 0 }}
transition={fade}
style={{ gridArea: "1 / 1", fontSize: 15, fontWeight: 640 }}
>
{onlineTitle}
</motion.span>
</span>
<span style={{ display: "grid", justifyItems: "center", marginTop: 5 }}>
<motion.span
initial={{ opacity: 0, y: rise }}
animate={{ opacity: online ? 0 : 0.56, y: 0 }}
transition={{ ...fade, delay: 0.12 }}
style={{ gridArea: "1 / 1", fontSize: 12.5 }}
>
{offlineMessage}
</motion.span>
<motion.span
initial={false}
animate={{ opacity: online ? 0.56 : 0 }}
transition={fade}
style={{ gridArea: "1 / 1", fontSize: 12.5 }}
>
{onlineMessage}
</motion.span>
</span>
<motion.button
type="button"
onClick={retry}
initial={{ opacity: 0, y: rise }}
animate={{ opacity: online ? 0 : 1, y: 0 }}
transition={{ ...fade, delay: online ? 0 : 0.18 }}
style={{
font: "inherit",
fontSize: 12.5,
fontWeight: 600,
color: "inherit",
background: tone(7),
border: `1px solid ${tone(16)}`,
borderRadius: 10,
padding: "8px 15px",
marginTop: 16,
cursor: online || attempting ? "default" : "pointer",
pointerEvents: online ? "none" : "auto",
}}
>
{/* Both labels share one cell, so an attempt cannot resize the
button under the cursor. */}
<span style={{ display: "grid", placeItems: "center" }}>
<motion.span
animate={{ opacity: attempting ? 0 : 1 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ gridArea: "1 / 1", whiteSpace: "nowrap" }}
>
{retryLabel}
</motion.span>
<motion.span
animate={{ opacity: attempting ? 1 : 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{ gridArea: "1 / 1", whiteSpace: "nowrap" }}
>
{attemptingLabel}
</motion.span>
</span>
</motion.button>
<span
role="status"
aria-live="polite"
style={{
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clipPath: "inset(50%)",
whiteSpace: "nowrap",
}}
>
{online ? onlineTitle : attempting ? attemptingLabel : offlineTitle}
</span>
</div>
);
}About this pattern
The drawing carries the state rather than decorating it. Two halves of a link sit apart with short break marks above them, and when the attempt succeeds the halves travel toward each other on the same spring, meeting in the middle while the marks fade and the bar between them appears. One illustration, two readings — no second artwork, no color change, nothing that needs a legend. The copy and the button crossfade in fixed cells so the block holds its exact size through the recovery. Reduced motion swaps the two readings without the travel.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Inbox
The unreachable-page screen leads with a single line drawing and a retry beneath it.
Related patterns
- Error RecoveryA failed panel settles without alarm, and the retry glyph turns exactly once per attempt.
- All Caught UpA bell settles, a small badge completes its mark in one stroke, and the list confirms you are current.
- Empty CartThe cart mark drops and settles once, then recently viewed items slide in as the way back to shopping.