All patterns

Login Error Recover

A refused sign-in expands its reason under the field, wipes only the password, and hands the caret back.

authenticationcalmminimalinteraction · finite · intermediate · ~0.9s
Interactive · click to play
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.

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

Rejected sign-in attemptWrong password on a re-auth promptExpired session retryLocked-account warning before a cooldown

Where it shows up

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

  • Sign inThat password didn't match — try again
    Email
    nils@ridgeline.co
    Password
    Continue
    Sign-in screen

    Only the password step is reset; the identifier already entered is kept.

Related patterns