Error Retry Nudge
A failed action answers with one short damped nudge and becomes its own retry.
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 { animate, motion, useMotionValue, useReducedMotion } from "motion/react";
/**
* Vibary · Error Retry Nudge
*
* A failed action answers with one short, damped nudge — out fast, back
* slow — then turns itself into the retry affordance with the reason
* underneath. Deliberately not a shake: repeating the movement would
* be the interface acting out.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Neutrals are mixed from the inherited text color, so it reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, the labels, `workingMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type ErrorRetryNudgeProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
idleLabel?: string;
workingLabel?: string;
retryLabel?: string;
doneLabel?: string;
/** Why it failed. Shown under the button once it has. */
errorText?: string;
/** How long the request appears to take, in ms. */
workingMs?: number;
/** How many attempts fail before one goes through. */
failAttempts?: number;
};
type Status = "idle" | "working" | "failed" | "done";
type VariantConfig = {
/** px of the single sideways nudge. Never more than this, never twice. */
nudge: number;
nudgeSeconds: number;
/** The reason row arriving under the button. */
spring: { type: "spring"; stiffness: number; damping: number };
revealSeconds: number;
};
// The whole point of this pattern is restraint, so the numbers are kept
// small on purpose: 4–6px, one time. Damping ratios (damping / 2√stiffness)
// stay at or above 0.8 so nothing that follows the nudge rings either.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely there. For an action that fails often enough to be routine.
subtle: {
nudge: 3,
nudgeSeconds: 0.2,
spring: { type: "spring", stiffness: 540, damping: 47 },
revealSeconds: 0.16,
},
// Enough to catch the eye that was already on the button. All-purpose.
default: {
nudge: 5,
nudgeSeconds: 0.28,
spring: { type: "spring", stiffness: 420, damping: 38 },
revealSeconds: 0.24,
},
// The upper bound of tasteful for a destructive or costly action.
playful: {
nudge: 7,
nudgeSeconds: 0.36,
spring: { type: "spring", stiffness: 310, damping: 30 },
revealSeconds: 0.32,
},
};
const ACCENT = "#7C7CF0";
const ERROR = "#E0564D";
const DONE_COLOR = "#2FA36B";
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
/** Literal rgba rather than color-mix: these three states tween into
* each other, and a color-mix string has nothing to interpolate. */
const SKINS: Record<Status, { background: string; color: string; border: string }> =
{
idle: { background: ACCENT, color: "#FFFFFF", border: ACCENT },
working: { background: ACCENT, color: "#FFFFFF", border: ACCENT },
failed: {
background: "rgba(224, 86, 77, 0.12)",
color: ERROR,
border: "rgba(224, 86, 77, 0.45)",
},
done: {
background: "rgba(47, 163, 107, 0.12)",
color: DONE_COLOR,
border: "rgba(47, 163, 107, 0.4)",
},
};
export default function ErrorRetryNudge({
variant = "default",
idleLabel = "Publish changes",
workingLabel = "Publishing…",
retryLabel = "Try again",
doneLabel = "Published",
errorText = "Could not reach the publishing service.",
workingMs = 900,
failAttempts = 1,
}: ErrorRetryNudgeProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [status, setStatus] = useState<Status>("idle");
const [attempt, setAttempt] = useState(0);
const x = useMotionValue(0);
useEffect(() => {
if (status !== "working") return;
const timer = setTimeout(() => {
// The first attempt fails and the retry goes through: the recovery
// is half the pattern, so the demo has to reach it.
setStatus(attempt <= failAttempts ? "failed" : "done");
}, workingMs);
return () => clearTimeout(timer);
}, [status, attempt, failAttempts, workingMs]);
useEffect(() => {
// Reduced motion: no nudge. The color change and the reason line
// carry the failure on their own.
if (status !== "failed" || reduceMotion) return;
const controls = animate(x, [0, -cfg.nudge, 0], {
duration: cfg.nudgeSeconds,
// Out fast, back slow — a single damped bump, not an oscillation.
times: [0, 0.35, 1],
ease: [0.22, 1, 0.36, 1],
});
return () => controls.stop();
}, [status, attempt, reduceMotion, x, cfg.nudge, cfg.nudgeSeconds]);
const start = () => {
if (status === "working") return;
setAttempt(status === "done" ? 1 : attempt + 1);
setStatus("working");
};
const skin = SKINS[status];
const failed = status === "failed";
const labels: { key: Status; text: string; icon?: "retry" | "done" }[] = [
{ key: "idle", text: idleLabel },
{ key: "working", text: workingLabel },
{ key: "failed", text: retryLabel, icon: "retry" },
{ key: "done", text: doneLabel, icon: "done" },
];
const widest = labels.reduce(
(longest, entry) => (entry.text.length > longest.length ? entry.text : longest),
""
);
return (
<div style={{ width: 264 }}>
<motion.button
type="button"
onClick={start}
aria-live="polite"
initial={false}
animate={{
backgroundColor: skin.background,
color: skin.color,
borderColor: skin.border,
}}
transition={{ duration: 0.22, ease: "easeOut" }}
style={{
x,
width: "100%",
padding: "11px 16px",
borderRadius: 11,
borderWidth: 1,
borderStyle: "solid",
fontSize: 13.5,
fontWeight: 600,
fontFamily: "inherit",
cursor: status === "working" ? "progress" : "pointer",
}}
>
{/* Every label shares one slot sized to the longest of them, so
the button never resizes mid-state and the type never scales. */}
<span
style={{
position: "relative",
display: "inline-flex",
justifyContent: "center",
}}
>
<span aria-hidden style={{ visibility: "hidden", whiteSpace: "nowrap" }}>
{widest}
</span>
{labels.map((entry) => (
<motion.span
key={entry.key}
aria-hidden={entry.key !== status}
initial={false}
animate={{ opacity: entry.key === status ? 1 : 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{entry.icon === "retry" ? (
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M13.4 8a5.4 5.4 0 1 1-1.9-4.1"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
<path
d="M13.6 2.2v3.3h-3.3"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : null}
{entry.icon === "done" ? (
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M4 8.3 6.7 11 12 5.4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : null}
{entry.text}
</motion.span>
))}
</span>
</motion.button>
{/* A genuine size change, kept short and eased; the line inside
rides a spring so the reason arrives rather than appears. */}
<motion.div
initial={false}
animate={{ height: failed ? "auto" : 0, opacity: failed ? 1 : 0 }}
transition={{
height: {
duration: reduceMotion ? 0.16 : cfg.revealSeconds,
ease: [0.4, 0, 0.2, 1],
},
opacity: { duration: 0.18, ease: "easeOut" },
}}
style={{ overflow: "hidden" }}
>
<motion.div
initial={false}
animate={{ y: failed || reduceMotion ? 0 : 5 }}
transition={reduceMotion ? { duration: 0 } : cfg.spring}
style={{
display: "flex",
alignItems: "flex-start",
gap: 7,
marginTop: 9,
padding: "8px 10px",
borderRadius: 9,
background: tone(6),
border: `1px solid ${tone(10)}`,
fontSize: 11.5,
lineHeight: 1.45,
}}
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
aria-hidden
style={{ flexShrink: 0, marginTop: 1 }}
>
<circle cx="8" cy="8" r="6.4" stroke={ERROR} strokeWidth="1.5" />
<path
d="M8 4.9v3.5"
stroke={ERROR}
strokeWidth="1.7"
strokeLinecap="round"
/>
<circle cx="8" cy="11" r="0.9" fill={ERROR} />
</svg>
<span style={{ opacity: 0.72 }}>{errorText}</span>
</motion.div>
</motion.div>
</div>
);
}About this pattern
What a button should do when the request comes back wrong. It moves 4–6px sideways exactly once — out fast, back slow — recolors to the error state, and turns into the retry affordance with the reason opening underneath. The repeated left-right shake this replaces is a cartoon convention: it reads as the interface being upset, it costs the reader half a second before they can act, and it says nothing the color and the reason line do not. Reduced motion drops the nudge and keeps everything that carries meaning.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Dashboard
A failed action recolors in place and states the reason beneath, without theatrics.
Related patterns
- Inline Error RevealA field marks itself invalid: the error ring fades on and the message expands into place below.
- Banner DismissAn announcement strip fades its message, then collapses its own height so the page closes the gap.
- Delete Confirm MorphA delete control widens in place into a question with a way out, instead of opening a dialog.