All patterns

OTP Code Entry

Six boxes: the waiting box lifts, digits settle in, and a wrong code answers with one short nudge.

authenticationfriendlyminimalinteraction · finite · intermediate · ~0.4s
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.

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

Two-factor sign-inPhone number verificationEmail code entryStep-up check at checkout

Where it shows up

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

  • Enter your codeWe sent six digits to your phone
    Verification code
    4 8 2 1 0 6
    Verify
    Two-factor prompt

    A refused entry gets one restrained shake, then the field is ready again.

Related patterns