All patterns

OTP Paste Fill

A pasted block of digits lands in the boxes as a quick left-to-right cascade instead of appearing all at once.

formsfriendlyenergeticinteraction · 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.

302 lines · react + motion only
import { useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · OTP Paste Fill
 *
 * A pasted or autofilled code lands in the boxes as a quick left-to-right
 * cascade: each digit fades in a beat after the one before it, with a
 * light sweeping along behind them. Typing stays instant — only an
 * arriving block of digits earns the cascade.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are mixed
 * from the inherited text color, so the field reads correctly on a light
 * page and on a dark one.
 * Works with zero props; tune via `variant`, `length`, `sampleCode`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type OtpPasteFillProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Number of boxes. */
  length?: number;
  /** Code the offered suggestion pastes in. Wire this to your own source. */
  sampleCode?: string;
  /** Accent for filled boxes and the sweep. */
  accent?: string;
  /** Fires once every box is filled. */
  onComplete?: (code: string, pasted: boolean) => void;
};

type VariantConfig = {
  /** Seconds between one box lighting up and the next. */
  step: number;
  /** Seconds for a single digit to arrive. */
  digit: number;
  /** How far a digit falls into its box, in px. */
  drop: number;
  /** Seconds for the sweep passing over a box. */
  sweep: number;
};

// Quality rule: the cascade is a sequence, not a race. The per-box step is
// small enough that six boxes finish inside a third of a second — long
// enough to see the direction, short enough that nobody waits for their
// own clipboard. Digits are text: they fade and fall a few pixels, they
// never scale and never overshoot, so the tween is eased rather than
// sprung.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely staggered — the code is simply there, with a hint of order.
  subtle: { step: 0.028, digit: 0.1, drop: 2, sweep: 0.19 },
  // The left-to-right reading is unmistakable. All-purpose.
  default: { step: 0.045, digit: 0.16, drop: 5, sweep: 0.3 },
  // A longer run for a screen where the autofill is the moment.
  playful: { step: 0.069, digit: 0.22, drop: 6, sweep: 0.38 },
};

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface, border or fill that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function OtpPasteFill({
  variant = "default",
  length = 6,
  sampleCode = "482913",
  accent = "#5B5BD6",
  onComplete,
}: OtpPasteFillProps) {
  const [value, setValue] = useState("");
  const [focused, setFocused] = useState(false);
  /** Bumped on every paste so the boxes remount and replay the cascade. */
  const [fill, setFill] = useState({ id: 0, from: 0 });
  const inputRef = useRef<HTMLInputElement>(null);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const apply = (raw: string) => {
    const next = raw.replace(/[^0-9]/g, "").slice(0, length);
    // More than one new character at once is a paste, an autofill or a
    // one-time-code suggestion — the only cases that cascade. Typing adds
    // a single character and must stay immediate.
    const pasted = next.length - value.length > 1;
    if (pasted) setFill((prev) => ({ id: prev.id + 1, from: value.length }));
    setValue(next);
    if (next.length === length) onComplete?.(next, pasted);
  };

  const clear = () => {
    setValue("");
    setFill({ id: 0, from: 0 });
    inputRef.current?.focus();
  };

  const complete = value.length === length;
  const status = complete
    ? fill.id > 0
      ? "Code filled from your messages"
      : "Code complete"
    : `${value.length} of ${length} digits`;

  return (
    <div
      style={{
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        gap: 12,
        color: "inherit",
      }}
    >
      <div style={{ position: "relative", display: "flex", gap: 8 }}>
        {Array.from({ length }, (_, index) => {
          const char = value[index] ?? "";
          const waiting = focused && index === value.length;
          // Only the boxes this paste filled are staggered; anything that
          // was already on screen stays where it is.
          const cascading = fill.id > 0 && index >= fill.from && char !== "";
          const delay =
            cascading && !reduceMotion ? (index - fill.from) * cfg.step : 0;

          return (
            <div
              key={index}
              style={{
                position: "relative",
                width: 40,
                height: 48,
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                borderRadius: 10,
                border: `1.5px solid ${
                  char ? accent : waiting ? tone(38) : tone(18)
                }`,
                background: tone(char ? 8 : 5),
                overflow: "hidden",
                // Border and surface are state, not motion: a CSS
                // transition keeps them off the animation loop.
                transition:
                  "border-color 180ms ease-out, background-color 180ms ease-out",
              }}
            >
              {/* The sweep. One pass of light per box, offset by the same
                  delay as the digit, so the row reads left to right even
                  before the glyphs land. */}
              {cascading && !reduceMotion && (
                <motion.span
                  key={`sweep-${fill.id}-${index}`}
                  aria-hidden
                  initial={{ opacity: 0 }}
                  animate={{ opacity: [0, 0.85, 0] }}
                  transition={{
                    duration: cfg.sweep,
                    delay,
                    times: [0, 0.3, 1],
                    ease: "easeOut",
                  }}
                  style={{
                    position: "absolute",
                    inset: 0,
                    borderRadius: 9,
                    background: `color-mix(in srgb, ${accent} 30%, transparent)`,
                  }}
                />
              )}

              {char ? (
                <motion.span
                  // Re-keyed per fill so a pasted digit plays its arrival
                  // once and a retyped one does not replay behind it.
                  key={`digit-${fill.id}-${index}-${char}`}
                  initial={
                    reduceMotion
                      ? { opacity: 0 }
                      : { opacity: 0, y: -cfg.drop }
                  }
                  animate={{ opacity: 1, y: 0 }}
                  transition={{
                    duration: reduceMotion ? 0.1 : cfg.digit,
                    delay,
                    ease: "easeOut",
                  }}
                  style={{
                    position: "relative",
                    fontSize: 19,
                    fontWeight: 600,
                    fontVariantNumeric: "tabular-nums",
                    lineHeight: 1,
                  }}
                >
                  {char}
                </motion.span>
              ) : (
                waiting && (
                  <span
                    aria-hidden
                    style={{
                      width: 1.5,
                      height: 20,
                      borderRadius: 1,
                      background: accent,
                      opacity: 0.7,
                    }}
                  />
                )
              )}
            </div>
          );
        })}

        {/* One real input behind the boxes carries the caret, the paste
            event and the platform's own one-time-code suggestion; the
            boxes only paint. Its outline is dropped because the accented
            box and caret are the focus indicator. */}
        <input
          ref={inputRef}
          value={value}
          onChange={(event) => apply(event.target.value)}
          onFocus={() => setFocused(true)}
          onBlur={() => setFocused(false)}
          inputMode="numeric"
          autoComplete="one-time-code"
          maxLength={length}
          aria-label={`${length} digit 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",
          }}
        />
      </div>

      {/* The suggestion strip the platform would offer. Pressing it is the
          same code path as a clipboard paste, so the cascade is reachable
          from the keyboard and in a demo with nothing on the clipboard. */}
      <button
        type="button"
        onClick={() => (complete ? clear() : apply(sampleCode))}
        style={{
          display: "flex",
          alignItems: "center",
          gap: 8,
          padding: "7px 12px",
          borderRadius: 999,
          border: `1px solid ${tone(14)}`,
          background: tone(6),
          color: "inherit",
          fontFamily: "inherit",
          fontSize: 12,
          fontWeight: 550,
          cursor: "pointer",
        }}
      >
        <svg
          width="13"
          height="13"
          viewBox="0 0 20 20"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.8"
          strokeLinecap="round"
          strokeLinejoin="round"
          aria-hidden
        >
          {complete ? (
            <path d="M5 5l10 10M15 5 5 15" />
          ) : (
            <>
              <rect x="4.5" y="3.5" width="11" height="13" rx="2.5" />
              <path d="M7.5 3.5V2.8h5v.7M7.5 8.5h5M7.5 12h3" />
            </>
          )}
        </svg>
        {complete ? "Clear code" : `Paste ${sampleCode}`}
      </button>

      <div
        role="status"
        aria-live="polite"
        style={{
          fontSize: 12,
          opacity: complete ? 0.8 : 0.5,
          color: complete ? accent : "inherit",
        }}
      >
        {status}
      </div>
    </div>
  );
}

About this pattern

Six digits arriving in the same frame look like a glitch: the field was empty, and now it is not. Staggering them by a few dozen milliseconds each turns the same event into something the eye can follow — a light passes along the row and the digits settle in behind it, finishing well inside half a second so nobody is waiting on their own clipboard. Typing is deliberately excluded: a single new character appears immediately, because a person entering digits by hand is already generating the rhythm the cascade exists to supply. The platform's own suggestion strip runs the same code path, so an autofill and a clipboard paste behave identically.

Autofilled login codeClipboard paste into a code fieldStep-up check at checkoutDevice pairing code

Where it shows up

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

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

    The keyboard suggestion that drops a texted code into the field.

Related patterns