OTP Code Entry
Six boxes: the waiting box lifts, digits settle in, and a wrong code answers with one short nudge.
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 { motion, useReducedMotion } from "motion/react";
/**
* Vibary · OTP Code Entry
*
* Six code boxes: the box waiting for input lifts, each digit settles
* into place as it is typed, and a wrong code answers with one short
* damped nudge.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `length`, `expectedCode`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type OtpCodeEntryProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Number of boxes. */
length?: number;
/** Code the sample accepts. Wire `onSubmit` to your real check instead. */
expectedCode?: string;
/** Fires as soon as the last box is filled. */
onSubmit?: (code: string, accepted: boolean) => void;
/** Focus and success color. */
accent?: string;
/** Error color, used for the border and the status line. */
danger?: string;
};
type VariantConfig = {
/** How far the waiting box rises, in px. */
lift: number;
liftSpring: { type: "spring"; stiffness: number; damping: number };
/** Distance a digit falls before it settles, in px. */
digitDrop: number;
digitDuration: number;
/** The rejection nudge, in px. */
shake: number[];
shakeDuration: number;
};
// Quality rule: the rejection is a nudge, not a tantrum. Amplitude stays
// at 4–6px and the keyframes decay immediately — a repeated left-right
// oscillation reads as a cartoon and, on an error state, as panic.
// Lift springs sit at or above a 0.8 damping ratio so a box never wobbles
// under the digit it is holding.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// One clean nudge with no counter-swing. For high-frequency 2FA where
// a wrong code is routine, not an incident.
subtle: {
lift: 1.5,
liftSpring: { type: "spring", stiffness: 600, damping: 48 },
digitDrop: 2,
digitDuration: 0.1,
shake: [0, -4, 0],
shakeDuration: 0.09,
},
// A nudge and one small return. The all-purpose setting.
default: {
lift: 2.5,
liftSpring: { type: "spring", stiffness: 520, damping: 40 },
digitDrop: 5,
digitDuration: 0.18,
shake: [0, -5, 2, 0],
shakeDuration: 0.24,
},
// Slightly more travel everywhere, still one decaying pass — energy
// comes from distance, never from extra swings.
playful: {
lift: 4.4,
liftSpring: { type: "spring", stiffness: 380, damping: 32 },
digitDrop: 7,
digitDuration: 0.22,
shake: [0, -6, 2.5, 0],
shakeDuration: 0.32,
},
};
/** How long the wrong code stays on screen before the boxes clear. */
const ERROR_HOLD_MS = 700;
type Status = "idle" | "accepted" | "error";
export default function OtpCodeEntry({
variant = "default",
length = 6,
expectedCode = "123456",
onSubmit,
accent = "#5B5BD6",
danger = "#E5484D",
}: OtpCodeEntryProps) {
const [value, setValue] = useState("");
const [focused, setFocused] = useState(false);
const [status, setStatus] = useState<Status>("idle");
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// A rejected code is held long enough to read, then cleared so the
// next attempt starts from an empty field. Typing during the hold
// flips the status back to idle, which cancels this timer.
useEffect(() => {
if (status !== "error") return;
const timer = setTimeout(() => {
setValue("");
setStatus("idle");
}, ERROR_HOLD_MS);
return () => clearTimeout(timer);
}, [status]);
const handleChange = (raw: string) => {
const next = raw.replace(/[^0-9]/g, "").slice(0, length);
setValue(next);
if (next.length < length) {
setStatus("idle");
return;
}
const accepted = next === expectedCode;
setStatus(accepted ? "accepted" : "error");
onSubmit?.(next, accepted);
};
const statusText =
status === "accepted"
? "Code verified"
: status === "error"
? "That code did not match. Try again."
: `Enter the ${length}-digit code we sent you`;
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
}}
>
<motion.div
// The nudge lives on the row, not on each box: a code is one
// object being refused, so it moves as one.
animate={{ x: status === "error" && !reduceMotion ? cfg.shake : 0 }}
transition={{ duration: cfg.shakeDuration, ease: "easeOut" }}
style={{ position: "relative", display: "flex", gap: 8 }}
>
{Array.from({ length }, (_, index) => {
const char = value[index] ?? "";
const isWaiting = focused && index === value.length;
const borderColor =
status === "error"
? danger
: status === "accepted"
? accent
: isWaiting
? accent
: "rgba(127,127,140,0.32)";
return (
<motion.div
key={index}
animate={{ y: isWaiting && !reduceMotion ? -cfg.lift : 0 }}
transition={cfg.liftSpring}
style={{
width: 40,
height: 48,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: 10,
border: `1.5px solid ${borderColor}`,
background: "rgba(127,127,140,0.10)",
// Border color is a state change, not motion: a CSS
// transition keeps it off the animation loop, which stays
// transform/opacity only.
transition: "border-color 160ms ease-out",
}}
>
{char ? (
<motion.span
// Re-keying on the character makes each new digit its
// own element, so it plays its landing once.
key={`${index}-${char}`}
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, y: -cfg.digitDrop }
}
animate={{ opacity: 1, y: 0 }}
// A tween, not a spring: the glyph is text and must
// land flat — no overshoot, no scaling, ever.
transition={{
duration: reduceMotion ? 0.1 : cfg.digitDuration,
ease: "easeOut",
}}
style={{
fontSize: 19,
fontWeight: 600,
fontVariantNumeric: "tabular-nums",
lineHeight: 1,
}}
>
{char}
</motion.span>
) : null}
</motion.div>
);
})}
{/* One real input behind the boxes carries value, caret, paste,
backspace and one-time-code autofill; the boxes only paint.
Its own outline is dropped because the lifted, accented box is
the focus indicator — brighter than any default ring. */}
<input
value={value}
onChange={(event) => handleChange(event.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={length}
aria-label="Verification code"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
padding: 0,
border: "none",
background: "transparent",
color: "transparent",
caretColor: "transparent",
outline: "none",
fontSize: 16,
cursor: "pointer",
}}
/>
</motion.div>
{/* The result is text as well as motion: reduced-motion users and
screen readers get the same answer the shake gives everyone else. */}
<div
role="status"
aria-live="polite"
style={{
fontSize: 12.5,
textAlign: "center",
color:
status === "error"
? danger
: status === "accepted"
? accent
: "inherit",
opacity: status === "idle" ? 0.55 : 1,
}}
>
{statusText}
</div>
</div>
);
}About this pattern
Verification codes are typed under mild stress — the message just arrived, the code expires in a minute — so the field has to say where you are and whether it worked without a word. The box awaiting input lifts a couple of pixels and takes the accent color, each digit drops the last few pixels into place as it lands, and a rejected code shifts the whole row once and decays. The refusal is deliberately small: a repeated left-right oscillation reads as a cartoon, and on an error state it reads as panic. The result is also written out, so reduced-motion users and screen readers get the same answer.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Two-factor prompt
A refused entry gets one restrained shake, then the field is ready again.
Related patterns
- Login Error RecoverA refused sign-in expands its reason under the field, wipes only the password, and hands the caret back.
- Social Login ButtonsThe separator rules draw outward from their label, then the provider buttons rise in one after the next.
- Two Factor Method SwitchChoosing another second factor slides the selection ring, crossfades the instructions and re-arms the field.