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.
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 { 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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Two-factor prompt
The keyboard suggestion that drops a texted code into the field.
Related patterns
- File Drop Zone ActiveThe dashed outline energizes and the target lifts off the page while a file is held over it.
- Quantity AdjustThe count rolls in the direction it moved and the order total takes a brief tint.
- Slider Drag ValueThe handle stays under the finger while held, with a value bubble that rises on grab.