Accessible one-time password input where typed characters pop in and a pasted code staggers in. A drop-in replacement for input-otp, with the same API.
Installation
Install the packages
Add the shadcn/ui component
Animate it
The CLI finds your shadcn/ui components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.
shadcn/ui's InputOTP (with every base) is built on input-otp. Animate UI's input-otp exports exactly the same API (OTPInput, OTPInputContext, the REGEXP_* patterns and the types) and adds the animation. animate-ui animate input-otp swaps the input-otp import for it. It is built on input-otp, so keep that dependency (shadcn add input-otp installs it).
Usage
Import from Animate UI instead of input-otp. Everything else stays the same.
import {
OTPInput,
OTPInputContext,
REGEXP_ONLY_DIGITS,
} from '@animate-ui/react/primitives/input-otp';Then use shadcn/ui's component as usual:
import {
InputOTP,
InputOTPGroup,
InputOTPSeparator,
InputOTPSlot,
} from '@/components/ui/input-otp';
import { REGEXP_ONLY_DIGITS } from '@animate-ui/react/primitives/input-otp';
export function VerificationCode() {
return (
<InputOTP maxLength={6} pattern={REGEXP_ONLY_DIGITS}>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
</InputOTPGroup>
<InputOTPSeparator />
<InputOTPGroup>
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
);
}Animation
- A typed character lands at once, small and faint at the bottom center of its slot (at 70% of its size), then grows into place 50ms later, on a spring that never goes past its size (stiffness 500, damping 45: most of the way in ~175ms). It grows by a transform, smoothly: animating its font size instead, laid out again on every frame, snapped it up and down a pixel as it grew.
- A pasted or autofilled code staggers its characters by 50ms, like split text.
- The ring glides: the active slot's ring moves from slot to slot as you type, delete or move with the arrow keys, on the highlights' spring (stiffness 500, damping 40); as you type, the caret moves on at once and the ring follows once the character has landed (50ms), like the item highlight of Animate UI's menus. It's the ring your slot's styles paint (shadcn/ui's
data-[active=true]:ring-3, its color and its corners), worn by an overlay in the container while the slot paints its resting box-shadow; it's read again whenever the field renders, so an invalid code (aria-invalid) turns it red. It fades in where the focus lands, and out where it is when the field loses it. - Deleting from the end (Backspace): the slot empties at once and a copy of its character shrinks to half its size and fades out where it was, in 0.15s (from where it was, if it was still growing in). Characters that only shift because one before them was removed don't animate.
- A cleared code (several characters removed at once, like a refused code reset): the characters leave from the last to the first, 50ms apart, each dropping 6px into a 4px blur as it fades out, and the ring glides back to the first slot behind them.
- A completed code (the last slot filled) runs a wave through the slots: each rises 4px and springs back, 40ms after the one before, once the last character has mostly grown in. The ring and the characters still moving ride their slot's wave; completing it again restarts the wave.
<AnimateUIProvider tempo>paces the animation.- Under reduced motion, characters only fade (the slot's own text, no copy), without growing or staggering; the ring moves to the next slot at once (it still fades in and out), nothing shrinks or waves (a cleared code still leaves from the last to the first, fading through its blur without dropping), and the fake caret stops blinking.
How it works
The slots are your markup: shadcn/ui's InputOTPSlot renders the char of OTPInputContext as plain text. The context keeps input-otp's values (char stays a string), so nothing in the component changes. Instead, OTPInput compares each new value with the previous one to find the characters that were just entered, and animates their slots, the [data-slot="input-otp-slot"] elements of its container in index order, with the Web Animations API. The slot's own text is hidden meanwhile (its color, transparent), and a copy of the character, laid exactly as the slot lays its text (its box, borders, padding, layout and font), grows in over it by a scale and fades in, with the spring as a linear() easing; the copy goes in the frame the slot's text shows again, at the same place. Of the slot itself, only its color is animated, and while it's active its box-shadow is held inline at its resting value (its box-shadow transition is cancelled; its other transitions, its border color's, run as usual). The copies and the ring live in two aria-hidden overlays added to the container (data-slot="input-otp-ghosts" and "input-otp-ring"), which never take pointer events; the wave is a translate added to the slots' own (Web Animations, composite: 'add').
Custom slots animate too, as long as they carry data-slot="input-otp-slot", one per index, in order. Any CSS animation inside a slot (the fake caret's blink) is turned off under reduced motion.
API Reference
Every export has exactly the API of input-otp: OTPInput takes its props, render included, and OTPInputContext provides its slots, isFocused and isHovering. Animate UI adds no props.
Accessibility
The value lives in input-otp's single <input> (with autocomplete="one-time-code"): typing, paste, autofill, password managers and form submission are unchanged. The animation is visual only.
Credits
- input-otp by Guilherme Rodz.