Password Reset Sent
The request panel gives way to a confirmation, with the envelope settling in and its flap stroking closed.
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 { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Password Reset Sent
*
* The request panel gives way to the confirmation: the envelope settles
* into place, its flap strokes in, and the reassurance text follows.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `address`, `accent`, `sendDelayMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PasswordResetSentProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Address echoed back on the confirmation panel. */
address?: string;
/** How long the request stays in flight before it resolves, in ms. */
sendDelayMs?: number;
/** Accent used by the primary button and the envelope. */
accent?: string;
/** Fires once the confirmation panel is on screen. */
onSent?: () => void;
};
type VariantConfig = {
/** Travel of the arriving panel, in px. */
travel: number;
/** Seconds the outgoing panel takes to clear. */
exit: number;
/** How far under full size the envelope starts. */
glyphFrom: number;
glyphSpring: { type: "spring"; stiffness: number; damping: number };
/** Seconds the flap takes to stroke itself in. */
flap: number;
};
// Quality rule: only the envelope springs, and it is a glyph — the panel
// itself is mostly text, so it translates and fades and never changes
// size. Every spring sits above a 0.8 damping ratio: this screen is
// reassurance, and reassurance that bounces reads as a party.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a move. For products where a reset is routine housekeeping.
subtle: {
travel: 6,
exit: 0.13,
glyphFrom: 0.94,
glyphSpring: { type: "spring", stiffness: 560, damping: 46 },
flap: 0.24,
},
// The panel visibly replaces the panel before it. The all-purpose
// setting.
default: {
travel: 10,
exit: 0.16,
glyphFrom: 0.88,
glyphSpring: { type: "spring", stiffness: 460, damping: 40 },
flap: 0.32,
},
// More travel and a slower flap, so the confirmation lands as a small
// moment of relief rather than a status change.
playful: {
travel: 16,
exit: 0.19,
glyphFrom: 0.82,
glyphSpring: { type: "spring", stiffness: 400, damping: 34 },
flap: 0.42,
},
};
/** 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 or border correctly toned in
* either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
type Stage = "request" | "sending" | "sent";
export default function PasswordResetSent({
variant = "default",
address = "you@company.com",
sendDelayMs = 1100,
accent = "#5B5BD6",
onSent,
}: PasswordResetSentProps) {
const [stage, setStage] = useState<Stage>("request");
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// The request runs itself so the pattern demonstrates its own
// timeline. Wire `setStage` to your real submit handler instead.
useEffect(() => {
const start = setTimeout(() => setStage("sending"), sendDelayMs);
const finish = setTimeout(() => setStage("sent"), sendDelayMs + 520);
return () => {
clearTimeout(start);
clearTimeout(finish);
};
}, [sendDelayMs]);
useEffect(() => {
if (stage === "sent") onSent?.();
}, [stage, onSent]);
const panelMotion = reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, transition: { duration: 0.12 } },
transition: { duration: 0.18, ease: "easeOut" as const },
}
: {
initial: { opacity: 0, y: cfg.travel },
animate: { opacity: 1, y: 0 },
// Leaving is not worth watching: the outgoing panel clears
// upward on a quick ease-in instead of replaying its entrance.
exit: {
opacity: 0,
y: -cfg.travel * 0.6,
transition: { duration: cfg.exit, ease: "easeIn" as const },
},
transition: {
type: "spring" as const,
stiffness: 420,
damping: 38,
opacity: { duration: 0.2, ease: "easeOut" as const },
},
};
return (
<div
style={{
position: "relative",
width: 292,
height: 252,
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(6),
color: "inherit",
overflow: "hidden",
}}
>
{/* Both panels are absolutely positioned in one fixed box, so the
card never resizes under the swap and nothing below it moves.
`mode="wait"` keeps them from crossfading through each other —
two stacks of text at 50% opacity is unreadable. */}
<AnimatePresence mode="wait" initial={false}>
{stage === "sent" ? (
<motion.div
key="sent"
{...panelMotion}
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "20px 24px",
textAlign: "center",
}}
>
<motion.span
aria-hidden
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: cfg.glyphFrom }
}
animate={{ opacity: 1, scale: 1 }}
transition={
reduceMotion
? { duration: 0.18, ease: "easeOut" }
: {
...cfg.glyphSpring,
delay: 0.04,
opacity: { duration: 0.18, delay: 0.04 },
}
}
style={{
display: "grid",
placeItems: "center",
width: 46,
height: 46,
borderRadius: 14,
background: tone(9),
color: accent,
}}
>
<svg width="24" height="24" viewBox="0 0 40 32" fill="none">
<rect
x="2"
y="4"
width="36"
height="24"
rx="4"
stroke="currentColor"
strokeWidth="2.2"
/>
{/* The flap strokes in after the body has landed: the
envelope reads as being closed on your message, which
is exactly what just happened. */}
<motion.path
d="M4 7.5 20 19 36 7.5"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{
duration: reduceMotion ? 0 : cfg.flap,
delay: reduceMotion ? 0 : 0.16,
ease: "easeOut",
}}
/>
</svg>
</motion.span>
<div style={{ fontSize: 15.5, fontWeight: 650 }}>
Check your email
</div>
<p
style={{
margin: 0,
fontSize: 12.5,
lineHeight: 1.55,
opacity: 0.62,
}}
>
A reset link is on its way to{" "}
<span style={{ fontWeight: 600, opacity: 0.9 }}>{address}</span>.
It stops working in 30 minutes.
</p>
<button
type="button"
onClick={() => setStage("request")}
style={{
padding: "8px 12px",
fontSize: 12,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: `1px solid ${tone(14)}`,
borderRadius: 9,
cursor: "pointer",
}}
>
Use a different address
</button>
</motion.div>
) : (
<motion.div
key="request"
{...panelMotion}
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: 12,
padding: "20px 22px",
}}
>
<div>
<div style={{ fontSize: 15.5, fontWeight: 650 }}>
Reset your password
</div>
<div style={{ fontSize: 12.5, opacity: 0.6, marginTop: 4 }}>
We will email you a link to choose a new one.
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<label
htmlFor="vibary-prs-email"
style={{ fontSize: 11.5, fontWeight: 600, opacity: 0.6 }}
>
Account email
</label>
<input
id="vibary-prs-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>
<button
type="button"
onClick={() => setStage("sending")}
style={{
position: "relative",
width: "100%",
padding: "10px 14px",
fontSize: 13.5,
fontWeight: 600,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 9,
overflow: "hidden",
cursor: "pointer",
}}
>
{stage === "sending" ? "Sending the link" : "Email me a link"}
{/* The in-flight beat is a rule sweeping the button's
bottom edge — the label stays put, because a label that
moves while you read it is worse than no feedback. */}
<motion.span
aria-hidden
initial={false}
animate={{
scaleX: stage === "sending" && !reduceMotion ? 1 : 0,
opacity: stage === "sending" ? 1 : 0,
}}
transition={{ duration: 0.5, ease: "easeInOut" }}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 0,
height: 2,
background: "rgba(255,255,255,0.7)",
transformOrigin: "left",
}}
/>
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}About this pattern
The panel swap that ends a forgotten-password detour. Both states live in one fixed box, so the card never resizes under the change and nothing below it jumps — the outgoing panel clears upward on a quick ease-in while the arriving one translates in on a damped spring. The envelope is the only element that changes size, because it is a glyph and not a sentence, and its flap strokes closed a beat after the body lands so the picture reads as your message being sealed rather than as an icon switching on. The address is echoed back in the copy: the reassurance people actually want here is proof that the right inbox was used.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Sign-in screen
The request card becomes the confirmation in place, echoing the address back.
Related patterns
- Verify Email CheckThe pending ring strokes its way round while the link is checked, and the frame it closes on is the confirmed badge.
- Guest Mode LimitA guest hits the limit and the gate glides up over a soft fade, offering a way in.
- Login Error RecoverA refused sign-in expands its reason under the field, wipes only the password, and hands the caret back.