All patterns

No Connection

Two halves of a broken link stand apart, then close the gap in one settle when the attempt lands.

empty-statescalmfriendlyautomatic · finite · intermediate · ~3.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.

308 lines · react + motion only
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.

Disconnected full screenSync panel with no reachable serverMobile app without signalRealtime session dropped

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
    Nothing left to readWhen there is something to show, it appears here.
    Inbox

    The unreachable-page screen leads with a single line drawing and a retry beneath it.

Related patterns