Password Match Confirm
The check settles into the confirm field the moment the two entries are identical.
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, 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Sign-up flow
Field-level answers that arrive while typing rather than on submit.
Related patterns
- Sign-in Form EntranceHeading, fields and button rise into place in one quick sequence as the screen opens.
- Login Error RecoverA refused sign-in expands its reason under the field, wipes only the password, and hands the caret back.
- Inline Completion GhostA faint prediction appears ahead of the caret and firms up to real content when it is accepted.