Login Error Recover
A refused sign-in expands its reason under the field, wipes only the password, and hands the caret back.
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, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Login Error Recover
*
* A rejected sign-in expands its reason under the password field, wipes
* the password and nothing else, and puts the caret back where the next
* attempt starts.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `accent`, `danger`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type LoginErrorRecoverProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Address prefilled in the untouched field. */
address?: string;
/** How long the attempt stays in flight, in ms. */
checkMs?: number;
/** Primary button color. */
accent?: string;
/** Reason ring and text color. */
danger?: string;
/** Fires with the outcome of each attempt. */
onAttempt?: (accepted: boolean) => void;
};
type VariantConfig = {
/** Seconds the reason takes to expand to its height. */
expand: number;
/** Seconds the reason takes to collapse again. */
collapse: number;
/** Travel of the reason's text as it settles into the opened space. */
lift: number;
/** Opacity the wiped field starts from as it comes back. */
wipeFrom: number;
};
// Quality rule: an error should appear calmly and be readable the instant
// it lands, so the reveal is a short ease-out tween rather than a spring
// — a spring would drag the reason's text past its resting line and back.
// Nothing here scales, and the field is never shaken: the message already
// says what went wrong, and a jolt on top of it reads as scolding.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// The reason simply exists. For products where a mistyped password is
// a non-event.
subtle: {
expand: 0.16,
collapse: 0.12,
lift: 3,
wipeFrom: 0.55,
},
// The reason opens the space it needs and settles into it. The
// all-purpose setting.
default: {
expand: 0.22,
collapse: 0.15,
lift: 5,
wipeFrom: 0.35,
},
// A slower open, for a screen where a failed attempt is worth a beat
// of attention before the next try.
playful: {
expand: 0.28,
collapse: 0.18,
lift: 8,
wipeFrom: 0.2,
},
};
/** Theme-adaptive neutral: `currentColor` is the inherited text color —
* near-black on a light page, near-white on a dark one — so mixing it
* with `transparent` yields a surface, border or fill correctly toned
* in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
type Status = "idle" | "checking" | "rejected" | "accepted";
export default function LoginErrorRecover({
variant = "default",
address = "you@company.com",
checkMs = 620,
accent = "#5B5BD6",
danger = "#E5484D",
onAttempt,
}: LoginErrorRecoverProps) {
const [status, setStatus] = useState<Status>("idle");
const [secret, setSecret] = useState("placeholder");
const [attempt, setAttempt] = useState(0);
// Counts wipes, not attempts: the field is only re-keyed when it was
// actually emptied, so an accepted attempt does not blink.
const [wipes, setWipes] = useState(0);
const passwordRef = useRef<HTMLInputElement>(null);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const rejected = status === "rejected";
// The caret goes back to the only field that has to be retyped. This
// runs after the render that re-keys the input, so it lands on the
// fresh element rather than the one that was just replaced.
useEffect(() => {
if (status === "rejected") passwordRef.current?.focus();
}, [status]);
const submit = () => {
if (status === "checking" || status === "accepted") return;
setStatus("checking");
const next = attempt + 1;
window.setTimeout(() => {
// The sample rejects the first attempt and accepts the second, so
// both halves of the recovery are visible. Wire this to your own
// check.
const accepted = next > 1;
setAttempt(next);
setStatus(accepted ? "accepted" : "rejected");
// Only the secret is cleared. Re-typing an address you already
// typed correctly is the part people actually resent.
if (!accepted) {
setSecret("");
setWipes((count) => count + 1);
}
onAttempt?.(accepted);
}, checkMs);
};
const buttonLabel =
status === "checking"
? "Checking"
: status === "accepted"
? "Signed in"
: "Continue";
return (
<div
style={{
width: 292,
display: "flex",
flexDirection: "column",
gap: 12,
padding: 20,
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(6),
color: "inherit",
}}
>
<div>
<div style={{ fontSize: 15.5, fontWeight: 650 }}>Welcome back</div>
<div style={{ fontSize: 12, opacity: 0.58, marginTop: 3 }}>
Continue to your workspace.
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label
htmlFor="vibary-ler-email"
style={{ fontSize: 11.5, fontWeight: 600, opacity: 0.6 }}
>
Email
</label>
<input
id="vibary-ler-email"
type="email"
autoComplete="email"
defaultValue={address}
style={{
width: "100%",
boxSizing: "border-box",
padding: "9px 11px",
fontSize: 14,
fontFamily: "inherit",
color: "inherit",
borderRadius: 9,
border: `1px solid ${tone(16)}`,
background: tone(8),
}}
/>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label
htmlFor="vibary-ler-password"
style={{ fontSize: 11.5, fontWeight: 600, opacity: 0.6 }}
>
Password
</label>
<div style={{ position: "relative" }}>
{/* Re-keying on the wipe makes the emptied field its own
element, so it fades back up once as it is handed over —
proof the box was cleared, with nothing moving. */}
<motion.input
key={wipes}
ref={passwordRef}
id="vibary-ler-password"
type="password"
autoComplete="current-password"
value={secret}
onChange={(event) => {
setSecret(event.target.value);
if (status === "rejected") setStatus("idle");
}}
placeholder="Your password"
initial={
wipes === 0 || reduceMotion
? { opacity: 1 }
: { opacity: cfg.wipeFrom }
}
animate={{ opacity: 1 }}
transition={{ duration: 0.24, ease: "easeOut" }}
style={{
width: "100%",
boxSizing: "border-box",
padding: "9px 11px",
fontSize: 14,
fontFamily: "inherit",
color: "inherit",
borderRadius: 9,
border: `1px solid ${tone(16)}`,
background: tone(8),
}}
/>
{/* The field "turns red" by fading a ring over it rather than
tweening the real border: the change stays on the
compositor and lands on the exact hue. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: rejected ? 1 : 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
borderRadius: 9,
border: `1.5px solid ${danger}`,
pointerEvents: "none",
}}
/>
</div>
</div>
{/* Height is the one dimension animated here, because the reason
genuinely takes space the form did not have — everything below
moves down by exactly its height, once, on a short ease. */}
<motion.div
initial={false}
animate={{ height: rejected ? "auto" : 0, opacity: rejected ? 1 : 0 }}
transition={{
duration: reduceMotion
? 0
: rejected
? cfg.expand
: cfg.collapse,
ease: rejected ? "easeOut" : "easeIn",
}}
style={{ overflow: "hidden" }}
>
<motion.div
role="alert"
animate={{ y: rejected && !reduceMotion ? 0 : -cfg.lift }}
transition={{ duration: cfg.expand, ease: "easeOut" }}
style={{
display: "flex",
gap: 8,
padding: "9px 10px",
borderRadius: 10,
border: `1px solid color-mix(in srgb, ${danger} 32%, transparent)`,
background: `color-mix(in srgb, ${danger} 10%, transparent)`,
}}
>
<svg
aria-hidden
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
style={{ flex: "0 0 auto", marginTop: 1, color: danger }}
>
<circle
cx="8"
cy="8"
r="6.4"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M8 4.8v3.8"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<circle cx="8" cy="11.1" r="0.9" fill="currentColor" />
</svg>
<span style={{ fontSize: 11.5, lineHeight: 1.45 }}>
<span style={{ fontWeight: 600, color: danger }}>
That password did not match.
</span>{" "}
<span style={{ opacity: 0.68 }}>
Your email is still here — try the password again, or reset it.
</span>
</span>
</motion.div>
</motion.div>
<button
type="button"
onClick={submit}
style={{
position: "relative",
width: "100%",
height: 38,
fontSize: 13.5,
fontWeight: 600,
fontFamily: "inherit",
color: "#ffffff",
background: status === "accepted" ? "#2E9E6B" : accent,
border: "none",
borderRadius: 9,
overflow: "hidden",
cursor: status === "checking" ? "default" : "pointer",
transition: "background-color 220ms ease-out",
}}
>
{/* Three labels stacked in one fixed box: the button never
changes width, so nothing under it shifts while the attempt
is in flight. */}
{["Continue", "Checking", "Signed in"].map((label) => (
<motion.span
key={label}
initial={false}
animate={{ opacity: label === buttonLabel ? 1 : 0 }}
transition={{ duration: 0.16, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
}}
>
{label}
</motion.span>
))}
<motion.span
aria-hidden
initial={false}
animate={{
scaleX: status === "checking" && !reduceMotion ? 1 : 0,
opacity: status === "checking" ? 1 : 0,
}}
transition={{ duration: checkMs / 1000, ease: "easeInOut" }}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: 2,
background: "rgba(255,255,255,0.7)",
transformOrigin: "left",
}}
/>
</button>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11.5,
opacity: 0.5,
}}
>
<span>Forgot password?</span>
<span>Use a one-time link</span>
</div>
</div>
);
}About this pattern
Most sign-in failures are a typo, so the interesting part of this pattern is not the refusal — it is the handover to the next attempt. The reason opens the space it needs on a short ease-out rather than a spring, because a spring drags the sentence past its resting line and back while someone is trying to read it. The address survives untouched, only the secret is wiped, and re-keying the emptied box makes it fade back up once as proof it was cleared. The caret is already in it by the time the reason finishes opening. Nothing is shaken: the message says what happened, and a jolt on top of it reads as scolding.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Sign-in screen
Only the password step is reset; the identifier already entered is kept.
Related patterns
- Password Reset SentThe request panel gives way to a confirmation, with the envelope settling in and its flap stroking closed.
- Sign-in Form EntranceHeading, fields and button rise into place in one quick sequence as the screen opens.
- Trust This DeviceFlipping the switch drops the device into the trusted list below, already marked.