All patterns

Error Recovery

A failed panel settles without alarm, and the retry glyph turns exactly once per attempt.

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

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

/**
 * Vibary · Error Recovery
 *
 * A failed section that stays useful. The block settles in without
 * alarm, and the only thing that moves on retry is the glyph inside the
 * button: one full turn, once, while the label crossfades in a slot
 * sized for both words so the button cannot resize mid-press. When the
 * data arrives the block hands over to it in the same cell.
 *
 * 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.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type RecoveredRow = {
  label: string;
  value: string;
};

export type ErrorRecoveryRetryProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Headline of the failed state. */
  title?: string;
  /** One line saying what went wrong, in plain words. */
  message?: string;
  /** Small technical line under the action, for support to quote. */
  detail?: string;
  /** Button labels, resting and in flight. */
  retryLabel?: string;
  retryingLabel?: string;
  /** Rows shown once the retry lands. */
  rows?: RecoveredRow[];
  /** How long the attempt takes before the rows arrive. */
  recoverMs?: number;
  /** Previews use this to press the button by themselves. Leave it out
   *  and nothing happens until a person retries. */
  autoRetryMs?: number;
  /** Fires each time a retry starts. */
  onRetry?: () => void;
  /** Accent used for the action. A literal brand color. */
  accent?: string;
  /** Block width — px number or any CSS length. */
  width?: number | string;
};

type VariantConfig = {
  /** px the block and the recovered rows travel. */
  rise: number;
  /** Seconds one full turn of the retry glyph takes. */
  turnSeconds: number;
  fadeSeconds: number;
  /** Seconds between recovered rows. */
  stagger: number;
  markSpring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the turn is a tween, not a spring, because a rotation
// that overshoots reads as a glitch rather than a retry. The one spring
// settles the mark and stays above 0.86 damping ratio
// (damping / 2√stiffness) in every variant.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // For a small panel among several — the failure should not shout.
  subtle: {
    rise: 5,
    turnSeconds: 0.5,
    fadeSeconds: 0.22,
    stagger: 0.04,
    markSpring: { type: "spring", stiffness: 440, damping: 42 },
  },
  // The all-purpose setting: a legible turn, a clear hand-off.
  default: {
    rise: 8,
    turnSeconds: 0.62,
    fadeSeconds: 0.28,
    stagger: 0.06,
    markSpring: { type: "spring", stiffness: 350, damping: 35 },
  },
  // For a full-width section where the retry is the only thing to do.
  playful: {
    rise: 12,
    turnSeconds: 0.74,
    fadeSeconds: 0.32,
    stagger: 0.08,
    markSpring: { type: "spring", stiffness: 280, damping: 29 },
  },
};

const ROWS: RecoveredRow[] = [
  { label: "API requests", value: "128,402" },
  { label: "Storage used", value: "46.1 GB" },
  { label: "Seats in use", value: "18 of 25" },
];

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` keeps the block correct on light and dark pages. The
 *  accent stays literal — it is the action, not a surface. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function ErrorRecoveryRetry({
  variant = "default",
  title = "We couldn't load this",
  message = "The request timed out before the data came back.",
  detail = "Reference 504 · 12:04",
  retryLabel = "Try again",
  retryingLabel = "Retrying",
  rows = ROWS,
  recoverMs = 1100,
  autoRetryMs,
  onRetry,
  accent = "#5B5BD6",
  width = 320,
}: ErrorRecoveryRetryProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [status, setStatus] = useState<"error" | "retrying" | "recovered">("error");
  // Counting attempts rather than toggling keeps every turn going the
  // same way; a glyph that unwinds on the second press looks like undo.
  const [attempts, setAttempts] = useState(0);

  const retry = () => {
    if (status !== "error") return;
    setStatus("retrying");
    setAttempts((count) => count + 1);
    onRetry?.();
  };

  // Uncontrolled by default so the file runs on its own; a preview can
  // press the button for the reader by passing `autoRetryMs`.
  useEffect(() => {
    if (autoRetryMs === undefined || status !== "error") return;
    const timer = setTimeout(() => {
      setStatus("retrying");
      setAttempts((count) => count + 1);
      onRetry?.();
    }, autoRetryMs);
    return () => clearTimeout(timer);
  }, [autoRetryMs, status, onRetry]);

  useEffect(() => {
    if (status !== "retrying") return;
    const timer = setTimeout(() => setStatus("recovered"), recoverMs);
    return () => clearTimeout(timer);
  }, [status, recoverMs]);

  const recovered = status === "recovered";
  const rise = reduceMotion ? 0 : cfg.rise;
  const fade = { duration: cfg.fadeSeconds, ease: "easeOut" as const };

  return (
    <div
      style={{
        position: "relative",
        width,
        boxSizing: "border-box",
        display: "grid",
      }}
    >
      {/* Failure and result share one cell: the panel is already the
          height of the taller of the two, so recovery cannot shove the
          page around at the moment it succeeds. */}
      <motion.div
        aria-hidden={recovered}
        animate={{ opacity: recovered ? 0 : 1 }}
        transition={fade}
        style={{
          gridArea: "1 / 1",
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          textAlign: "center",
          padding: "24px 20px",
          pointerEvents: recovered ? "none" : "auto",
        }}
      >
        <motion.div
          initial={{ opacity: 0, scale: reduceMotion ? 1 : 0.93 }}
          animate={{ opacity: 1, scale: 1 }}
          transition={
            reduceMotion
              ? { duration: 0.2, ease: "easeOut" }
              : { ...cfg.markSpring, opacity: fade }
          }
          style={{ lineHeight: 0, marginBottom: 13 }}
        >
          <BrokenPageMark />
        </motion.div>

        <motion.div
          initial={{ opacity: 0, y: rise }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ ...fade, delay: 0.07 }}
          style={{ fontSize: 14.5, fontWeight: 640 }}
        >
          {title}
        </motion.div>

        <motion.div
          initial={{ opacity: 0, y: rise }}
          animate={{ opacity: 0.58, y: 0 }}
          transition={{ ...fade, delay: 0.12 }}
          style={{ fontSize: 12.5, marginTop: 5, lineHeight: 1.5, maxWidth: 232 }}
        >
          {message}
        </motion.div>

        <motion.button
          type="button"
          onClick={retry}
          initial={{ opacity: 0, y: rise }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ ...fade, delay: 0.19 }}
          style={{
            font: "inherit",
            display: "inline-flex",
            alignItems: "center",
            gap: 7,
            fontSize: 12.5,
            fontWeight: 600,
            color: "#fff",
            background: accent,
            border: "none",
            borderRadius: 10,
            padding: "9px 15px",
            marginTop: 15,
            cursor: status === "error" ? "pointer" : "default",
          }}
        >
          <motion.span
            aria-hidden
            animate={{ rotate: reduceMotion ? 0 : attempts * 360 }}
            transition={{ duration: cfg.turnSeconds, ease: [0.65, 0, 0.35, 1] }}
            style={{ display: "inline-flex", lineHeight: 0 }}
          >
            <RetryGlyph />
          </motion.span>

          {/* Both labels stack in one cell, so the button reserves the
              wider reading and never resizes mid-attempt. */}
          <span style={{ display: "grid", placeItems: "center" }}>
            <motion.span
              animate={{ opacity: status === "error" ? 1 : 0 }}
              transition={{ duration: 0.16, ease: "easeOut" }}
              style={{ gridArea: "1 / 1" }}
            >
              {retryLabel}
            </motion.span>
            <motion.span
              animate={{ opacity: status === "error" ? 0 : 1 }}
              transition={{ duration: 0.16, ease: "easeOut" }}
              style={{ gridArea: "1 / 1" }}
            >
              {retryingLabel}
            </motion.span>
          </span>
        </motion.button>

        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 0.36 }}
          transition={{ ...fade, delay: 0.26 }}
          style={{ fontSize: 11, marginTop: 11 }}
        >
          {detail}
        </motion.div>
      </motion.div>

      <div
        aria-hidden={!recovered}
        style={{
          gridArea: "1 / 1",
          alignSelf: "center",
          padding: "8px 16px",
          pointerEvents: recovered ? "auto" : "none",
        }}
      >
        {rows.map((row, index) => (
          <motion.div
            key={row.label}
            initial={false}
            animate={{
              opacity: recovered ? 1 : 0,
              y: recovered ? 0 : rise,
            }}
            transition={{ ...fade, delay: recovered ? index * cfg.stagger : 0 }}
            style={{
              display: "flex",
              alignItems: "baseline",
              justifyContent: "space-between",
              gap: 12,
              padding: "11px 2px",
              borderTop: index === 0 ? "none" : `1px solid ${tone(8)}`,
            }}
          >
            <span style={{ fontSize: 12.5, opacity: 0.66 }}>{row.label}</span>
            <span
              style={{
                fontSize: 13,
                fontWeight: 620,
                fontVariantNumeric: "tabular-nums",
              }}
            >
              {row.value}
            </span>
          </motion.div>
        ))}
      </div>

      <span
        role="status"
        aria-live="polite"
        style={{
          position: "absolute",
          width: 1,
          height: 1,
          overflow: "hidden",
          clipPath: "inset(50%)",
          whiteSpace: "nowrap",
        }}
      >
        {status === "retrying" ? retryingLabel : recovered ? "Loaded" : title}
      </span>
    </div>
  );
}

/** Line art authored inline: a page with a fault running through it,
 *  stroked in `currentColor` so it inherits the page theme. */
function BrokenPageMark() {
  return (
    <svg width="48" height="52" viewBox="0 0 48 52" fill="none" aria-hidden>
      <g
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
        opacity="0.32"
      >
        <path d="M12 9.5a3 3 0 0 1 3 -3h12l9 9v27a3 3 0 0 1 -3 3H15a3 3 0 0 1 -3 -3z" />
        <path d="M27 6.5v9h9" />
      </g>
      <path
        d="M15 30l6-3.5 4 6 5-4.5 6 3"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeLinecap="round"
        strokeLinejoin="round"
        opacity="0.2"
      />
    </svg>
  );
}

/** The retry glyph: an almost-closed circle with a corner arrowhead, so
 *  a single turn reads as one attempt. */
function RetryGlyph() {
  return (
    <svg width="13" height="13" viewBox="0 0 14 14" fill="none" aria-hidden>
      <path
        d="M12.2 7A5.2 5.2 0 1 1 10.4 3.1"
        stroke="#fff"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
      <path
        d="M12.4 1.4v3.2H9.2"
        stroke="#fff"
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

About this pattern

A section that failed to load is still part of a working page, so the state arrives calmly: the mark settles once, the plain-words explanation follows, and the action sits under it with the reference number a support conversation will need. Retrying spends motion in one place — a single full turn of the glyph, tweened rather than sprung so it cannot overshoot — while the label crossfades inside a slot sized for both readings, keeping the button the same width mid-attempt. Recovered data replaces the block in the same grid cell, so nothing on the page jumps at the moment it works.

Failed data panelTimed-out requestSection that needs a reloadDashboard widget error

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • OverviewLast 30 days
    Revenue$48,210+12.4%
    Orders1,284+3.1%
    Refunds$1,940−0.8%
    Revenue by day
    Dashboard

    A metrics card that fails keeps its frame and offers a reload inside the same panel.

Related patterns