All patterns

Password Match Confirm

The check settles into the confirm field the moment the two entries are identical.

authenticationsubtleminimalautomatic · finite · starter · ~1.5s
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.

352 lines · react + motion only
import { useEffect, useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Password Match Confirm
 *
 * The confirm field agreeing with the first. Instead of waiting for a
 * submit to say no, the check settles in the moment the two entries are
 * identical — the answer arrives while the user is still typing.
 *
 * The confirm field types itself once on mount so the moment is visible;
 * touch it and the script stops, leaving ordinary controlled inputs.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Neutrals mix from the inherited text color, so the fields read
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `password`, `success`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type PasswordMatchConfirmProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Sample value in the first field — a placeholder, not a credential. */
  password?: string;
  /** Color of the agreement state. */
  success?: string;
  /** Fires the first time the two entries agree. */
  onMatch?: () => void;
};

type VariantConfig = {
  /** Seconds before the confirm field starts typing itself. */
  lead: number;
  /** Milliseconds between characters. */
  typeMs: number;
  /** How long the check takes to draw. */
  draw: number;
  /** Travel of the hint line as it changes, in px. */
  rise: number;
  spring: { type: "spring"; stiffness: number; damping: number };
};

// Quality rule: the hint is text, so it translates and fades and never
// scales. The check strokes rather than pops. The single spring here sits
// above a 0.8 damping ratio — a confirmation that wobbles undercuts the
// thing it is confirming.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Nearly invisible: the check is simply there once the values agree.
  subtle: {
    lead: 0.4,
    typeMs: 45,
    draw: 0.2,
    rise: 4,
    spring: { type: "spring", stiffness: 560, damping: 44 },
  },
  // The all-purpose setting: the check draws, the hint changes with it.
  default: {
    lead: 0.55,
    typeMs: 62,
    draw: 0.28,
    rise: 6,
    spring: { type: "spring", stiffness: 460, damping: 40 },
  },
  // A slower stroke, for a sign-up screen where this is the last hurdle.
  playful: {
    lead: 0.7,
    typeMs: 78,
    draw: 0.36,
    rise: 9,
    spring: { type: "spring", stiffness: 380, damping: 34 },
  },
};

/** Theme-adaptive neutral: `currentColor` is the inherited text color, so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned on a light page and on a dark one. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function PasswordMatchConfirm({
  variant = "default",
  password = "sample-passphrase-42",
  success = "#2FA36B",
  onMatch,
}: PasswordMatchConfirmProps) {
  const [typed, setTyped] = useState("");
  const [scripted, setScripted] = useState(true);
  const [shown, setShown] = useState(false);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const uid = useId();

  // Reduced motion keeps the outcome and drops the performance: the field
  // is simply already filled in, derived rather than typed.
  const confirm = scripted && reduceMotion ? password : typed;
  const matched = confirm.length > 0 && confirm === password;

  useEffect(() => {
    if (!scripted || reduceMotion) return;
    let index = 0;
    let interval: ReturnType<typeof setInterval> | undefined;
    const lead = setTimeout(() => {
      interval = setInterval(() => {
        index += 1;
        setTyped(password.slice(0, index));
        if (index >= password.length && interval) clearInterval(interval);
      }, cfg.typeMs);
    }, cfg.lead * 1000);
    return () => {
      clearTimeout(lead);
      if (interval) clearInterval(interval);
    };
  }, [scripted, password, reduceMotion, cfg.lead, cfg.typeMs]);

  useEffect(() => {
    if (matched) onMatch?.();
  }, [matched, onMatch]);

  const fieldStyle = {
    width: "100%",
    boxSizing: "border-box" as const,
    padding: "10px 38px 10px 12px",
    fontSize: 13,
    fontFamily: "inherit",
    color: "inherit",
    borderRadius: 10,
    background: tone(5),
    outline: "none",
  };

  return (
    <div
      style={{
        width: 300,
        padding: 18,
        borderRadius: 16,
        background: tone(6),
        color: "inherit",
        border: `1px solid ${tone(12)}`,
      }}
    >
      <div style={{ fontSize: 14, fontWeight: 650 }}>Choose a password</div>
      <div style={{ fontSize: 12, opacity: 0.55, marginTop: 4, lineHeight: 1.5 }}>
        At least twelve characters
      </div>

      <label
        htmlFor={`${uid}-new`}
        style={{
          display: "block",
          fontSize: 11.5,
          fontWeight: 600,
          letterSpacing: 0.2,
          opacity: 0.6,
          marginTop: 16,
          marginBottom: 6,
        }}
      >
        New password
      </label>
      <div style={{ position: "relative" }}>
        <input
          id={`${uid}-new`}
          type={shown ? "text" : "password"}
          value={password}
          readOnly
          autoComplete="new-password"
          style={{ ...fieldStyle, border: `1px solid ${tone(14)}` }}
        />
        <button
          type="button"
          aria-label={shown ? "Hide password" : "Show password"}
          onClick={() => setShown((current) => !current)}
          style={{
            position: "absolute",
            right: 6,
            top: 6,
            display: "grid",
            placeItems: "center",
            width: 28,
            height: 28,
            borderRadius: 8,
            border: "none",
            background: "transparent",
            color: "inherit",
            opacity: 0.55,
            cursor: "pointer",
          }}
        >
          <svg
            width="15"
            height="15"
            viewBox="0 0 20 20"
            fill="none"
            stroke="currentColor"
            strokeWidth="1.6"
            strokeLinecap="round"
            strokeLinejoin="round"
            aria-hidden
          >
            <path d="M2.6 10S5.4 5.4 10 5.4 17.4 10 17.4 10 14.6 14.6 10 14.6 2.6 10 2.6 10z" />
            <circle cx="10" cy="10" r="2.1" />
            {!shown && <path d="M4.4 15.6L15.6 4.4" />}
          </svg>
        </button>
      </div>

      <label
        htmlFor={`${uid}-confirm`}
        style={{
          display: "block",
          fontSize: 11.5,
          fontWeight: 600,
          letterSpacing: 0.2,
          opacity: 0.6,
          marginTop: 12,
          marginBottom: 6,
        }}
      >
        Confirm password
      </label>
      <div style={{ position: "relative" }}>
        <input
          id={`${uid}-confirm`}
          type={shown ? "text" : "password"}
          value={confirm}
          onChange={(event) => {
            // Typing takes over from the scripted demo, leaving an
            // ordinary controlled input behind.
            setScripted(false);
            setTyped(event.target.value);
          }}
          autoComplete="new-password"
          placeholder="Type it again"
          style={{
            ...fieldStyle,
            // The border is the quietest possible signal and it costs no
            // layout: a colour transition, not a ring that pops in.
            border: `1px solid ${matched ? success : tone(14)}`,
            transition: "border-color 200ms ease",
          }}
        />

        {/* The check occupies its slot whether or not it is drawn, so the
            field never reflows when the entries start agreeing. */}
        <span
          aria-hidden
          style={{
            position: "absolute",
            right: 10,
            top: 9,
            display: "grid",
            placeItems: "center",
            width: 20,
            height: 20,
          }}
        >
          <AnimatePresence initial={false}>
            {matched && (
              <motion.svg
                key="check"
                width="18"
                height="18"
                viewBox="0 0 20 20"
                fill="none"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.14 }}
                style={{ position: "absolute" }}
              >
                <motion.circle
                  cx="10"
                  cy="10"
                  r="8.4"
                  fill={success}
                  fillOpacity="0.16"
                  initial={{ opacity: reduceMotion ? 1 : 0 }}
                  animate={{ opacity: 1 }}
                  transition={{ duration: 0.18, ease: "easeOut" }}
                />
                <motion.path
                  d="M6 10.3l2.7 2.7L14 7.4"
                  stroke={success}
                  strokeWidth="2"
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  initial={{ pathLength: reduceMotion ? 1 : 0 }}
                  animate={{ pathLength: 1 }}
                  transition={
                    reduceMotion ? { duration: 0 } : { duration: cfg.draw, ease: "easeOut" }
                  }
                />
              </motion.svg>
            )}
          </AnimatePresence>
        </span>
      </div>

      {/* One line, two states. It swaps in place at a constant size, so
          the panel below it never moves. */}
      <div style={{ position: "relative", height: 17, marginTop: 8 }} aria-live="polite">
        <AnimatePresence mode="wait" initial={false}>
          <motion.div
            key={matched ? "match" : "pending"}
            initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: cfg.rise }}
            animate={{ opacity: 1, y: 0 }}
            exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -cfg.rise }}
            transition={
              reduceMotion
                ? { duration: 0.14, ease: "easeOut" }
                : { ...cfg.spring, opacity: { duration: 0.16, ease: "easeOut" } }
            }
            style={{
              position: "absolute",
              inset: 0,
              fontSize: 11.5,
              fontWeight: 600,
              color: matched ? success : "inherit",
              opacity: matched ? 1 : 0.5,
            }}
          >
            {matched ? "Both entries match" : "Type the same password again"}
          </motion.div>
        </AnimatePresence>
      </div>

      <button
        type="button"
        disabled={!matched}
        style={{
          width: "100%",
          marginTop: 14,
          padding: "11px 14px",
          fontSize: 13,
          fontWeight: 650,
          fontFamily: "inherit",
          borderRadius: 11,
          border: "none",
          background: "#5B5BD6",
          color: "#FFFFFF",
          opacity: matched ? 1 : 0.45,
          cursor: matched ? "pointer" : "default",
          transition: "opacity 200ms ease",
        }}
      >
        Set password
      </button>
    </div>
  );
}

About this pattern

Most sign-up screens wait for a submit to say the two entries disagree, which turns a typo into a round trip. Here the answer arrives while the user is still typing: the check strokes into the confirm field the moment the values are identical, the border shifts colour on a plain transition rather than popping a ring, and the hint line swaps in place at a constant size so nothing below it moves. The check occupies its slot whether or not it is drawn, which is what keeps the field from reflowing on the first agreeing character. The confirm field types itself once on mount so the moment is visible in a preview; touching it stops the script and leaves ordinary controlled inputs.

Account creation password entryPassword reset confirmationChange password settingsRepeat entry agreement check

Where it shows up

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

  • Create your accountFree while you are testing
    Legal name
    Ridgeline Supply Co.
    Country
    Sweden
    VAT number
    SE556031820101
    Create account
    Sign-up flow

    Field-level answers that arrive while typing rather than on submit.

Related patterns