All patterns

Password Reset Sent

The request panel gives way to a confirmation, with the envelope settling in and its flap stroking closed.

authenticationcalmfriendlyautomatic · finite · intermediate · ~2.0s
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.

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

Forgot password confirmationAccount recovery requestResend an invitationChange-of-email confirmation

Where it shows up

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

  • Sign inUse the address your team invited
    Email
    nils@ridgeline.co
    Password
    ••••••••••
    Continue
    Sign-in screen

    The request card becomes the confirmation in place, echoing the address back.

Related patterns