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-card

This 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

  1. 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.
  2. The slots lock (read-only, focus stays) and become the loader. With the default orbit variant they spiral out of the row into a ring, orbit, and glide back. The card grows to fit the ring and shrinks again after.
  3. onVerify runs at the same time. The result is only shown once the loader has finished at least minRotations full rotations. A fast server still gets the whole motion; a slow one keeps it going. A rotation is never cut off halfway.
  4. Success: the slots turn green with a staggered pop and onSuccess runs. Error: the row shakes, the message is announced, and focus returns to the first slot. The code stays so a typo can be fixed (pass clearOnError to wipe it).

Returning a result

onVerify can return a value or throw:

ReturnOutcome
true, undefined, anything elseSuccess
falseError 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-motion turns every variant into an opacity pulse and the error shake into a fade.

API Reference

Prop

Type

On this page