Blocks
OTP Verify Card
A verification-code card whose slots become the loader. They orbit, flip, spin or wave while the code is checked, then pop green or shake red.
Loading...
Installation
npx shadcn@latest add @fujin/otp-verify-cardThis pulls in otp-field and button. The motion runs on the Web Animations
API, so there is no CSS to add.
Usage
import { OtpVerifyCard } from "@/components/otp-verify-card"<OtpVerifyCard
length={6}
groupSize={3}
onVerify={async (code) => {
const res = await fetch("/api/verify", {
method: "POST",
body: JSON.stringify({ code }),
})
return res.ok || { ok: false, message: "That code has expired." }
}}
onSuccess={() => router.push("/dashboard")}
onResend={() => fetch("/api/resend", { method: "POST" })}
/>How it works
- The user fills the slots. In
submitMode="auto"(the default) verification starts on the last character or a full paste; in"manual"it waits for the Verify button or Enter. - The slots lock (read-only, focus stays) and become the loader. With the
default
orbitvariant they spiral out of the row into a ring, orbit, and glide back. The card grows to fit the ring and shrinks again after. onVerifyruns at the same time. The result is only shown once the loader has finished at leastminRotationsfull rotations. A fast server still gets the whole motion; a slow one keeps it going. A rotation is never cut off halfway.- Success: the slots turn green with a staggered pop and
onSuccessruns. Error: the row shakes, the message is announced, and focus returns to the first slot. The code stays so a typo can be fixed (passclearOnErrorto wipe it).
Returning a result
onVerify can return a value or throw:
| Return | Outcome |
|---|---|
true, undefined, anything else | Success |
false | Error with errorMessage |
{ ok: false, message } | Error with message |
throws Error(message) | Error with the error's message |
Animation
Every timing value can be changed through animation:
<OtpVerifyCard
onVerify={verify}
animation={{
variant: "flip", // "orbit" | "flip" | "spin" | "wave" | "pulse"
duration: 900, // ms per full rotation
minRotations: 2, // always complete at least two
stagger: 120, // ms between slots (flip/spin/wave/pulse)
easing: "cubic-bezier(0.65, 0, 0.35, 1)",
}}
/>Prop
Type
Headless
The state machine and the motion are separate from the markup. Build your
own layout with useOtpVerify and keep the same behaviour:
import { useOtpVerify } from "@/lib/otp-verify/use-otp-verify"
const { stageRef, ...otp } = useOtpVerify({ length: 6, onVerify, animation: { variant: "spin" } })
<div ref={stageRef} className="flex items-center justify-center">
<OTPField
length={6}
value={otp.value}
onValueChange={otp.setValue}
onValueComplete={otp.handleComplete}
readOnly={otp.isLocked}
>
{/* six <OTPFieldSlot /> */}
</OTPField>
</div>
<p role="status">{otp.status === "error" ? otp.error : null}</p>stageRef must wrap only the slots, in one row and centred vertically. The
orbit measures from it and grows its padding to fit the ring.
Accessibility
- Status changes (verifying, verified, the error message) are announced
through a
role="status"region that also describes the field. - Slots are read-only rather than disabled while verifying, so focus is not lost.
- The code is kept after an error (WCAG 3.3.7). Paste and one-time-code autofill are never blocked (WCAG 3.3.8).
prefers-reduced-motionturns every variant into an opacity pulse and the error shake into a fade.
API Reference
Prop
Type