clerk-otp

PreviousNext

A clerk OTP component.

Docs
eldorauiui

Preview

Loading preview…
registry/eldoraui/clerk-otp.tsx
"use client"

import { useEffect, useState } from "react"
import { motion } from "motion/react"

import { cn } from "@/lib/utils"

const generateRandomDigits = () => {
  return Array.from({ length: 6 }, () =>
    Math.floor(Math.random() * 10).toString()
  )
}

type AnimatedOTPProps = {
  delay?: number
  cardTitle?: string
  cardDescription?: string
  whileHover?: boolean
}

const AnimatedOTP = ({
  delay = 3500,
  cardTitle = "Multifactor Authentication",
  cardDescription = "Each user's self-serve multifactor settings are enforced automatically during sign-in.",
  whileHover = false,
}: AnimatedOTPProps) => {
  const [animationKey, setAnimationKey] = useState(0)
  const delayTime = Math.max(delay, 3500)
  useEffect(() => {
    const interval = setInterval(() => {
      setAnimationKey((prev) => prev + 1)
    }, delayTime)

    return () => clearInterval(interval)
  }, [delayTime])

  return (
    <OTPinput
      key={animationKey}
      cardTitle={cardTitle}
      cardDescription={cardDescription}
      whileHover={whileHover}
    />
  )
}

export default AnimatedOTP

const OTPinput = ({
  cardTitle,
  cardDescription,
  whileHover,
}: AnimatedOTPProps) => {
  const [activeIndex, setActiveIndex] = useState(0)
  const [fadeOut, setFadeOut] = useState(false)
  const [digits] = useState(() => generateRandomDigits())
  const [isCardHovered, setIsCardHovered] = useState(false)

  useEffect(() => {
    if (activeIndex > digits.length - 1) return

    const shouldAnimate = !whileHover || (whileHover && isCardHovered)

    if (!shouldAnimate) return

    const interval = setInterval(() => {
      setActiveIndex((prev) => prev + 1)
    }, 400)

    if (activeIndex === digits.length - 1) {
      setTimeout(() => {
        setFadeOut(true)
      }, 450)
    }

    return () => clearInterval(interval)
  }, [activeIndex, digits.length, whileHover, isCardHovered])

  return (
    <motion.div
      initial={{ opacity: 1 }}
      onHoverStart={() => setIsCardHovered(true)}
      onHoverEnd={() => setIsCardHovered(false)}
      className={cn(
        "relative",
        "flex items-center justify-center",
        "h-[14rem] w-full max-w-[350px]",
        "rounded-md border bg-neutral-50 dark:bg-neutral-900",
        "shadow-[0_3px_10px_rgb(0,0,0,0.2)]"
      )}
    >
      <div className="absolute top-[25%] left-1/2 -translate-x-1/2">
        <div className="flex w-full items-center justify-center gap-3">
          {digits.map((digit, idx) => (
            <div
              key={idx}
              className={cn(
                "text-primary relative flex h-10 w-8 cursor-default items-center justify-center rounded-md bg-gradient-to-br from-neutral-100 to-neutral-50 dark:from-neutral-800 dark:to-neutral-800",
                "shadow-[0_3px_10px_rgb(0,0,0,0.2)]"
              )}
            >
              {(!whileHover || (whileHover && isCardHovered)) && (
                <motion.div
                  className="absolute inset-0 rounded-md border border-cyan-400"
                  initial={{ opacity: 0 }}
                  animate={{ opacity: [0, 1, 0] }}
                  transition={{
                    duration: 0.5,
                    ease: "easeInOut",
                    delay: 2.25,
                  }}
                  style={{
                    boxShadow: "inset 0 0 12px rgba(34, 211, 238, 0.5)",
                  }}
                />
              )}
              {activeIndex === idx &&
                (!whileHover || (whileHover && isCardHovered)) && (
                  <motion.div
                    key={idx}
                    className="absolute inset-0 rounded-md border border-cyan-400"
                    initial={{ opacity: 0 }}
                    animate={{ opacity: 1 }}
                    transition={{
                      duration: 0.3,
                      ease: "easeInOut",
                    }}
                    style={{
                      boxShadow: "inset 0 0 12px rgba(34, 211, 238, 0.6)",
                    }}
                  >
                    <svg
                      viewBox="0 0 20 20"
                      className="absolute inset-0 h-full w-full"
                      strokeWidth="0.4"
                    >
                      <path
                        d="M 3 19 h 14"
                        className="stroke-cyan-400 dark:stroke-cyan-500"
                      />
                    </svg>
                  </motion.div>
                )}
              <motion.span
                initial={{ opacity: 0 }}
                animate={{
                  opacity: whileHover
                    ? isCardHovered
                      ? fadeOut
                        ? 0
                        : 1
                      : 0
                    : fadeOut
                      ? 0
                      : 1,
                }}
                transition={{
                  duration: fadeOut ? 0.1 : 0.2,
                  ease: "easeInOut",
                  delay: fadeOut ? 0 : idx * 0.43,
                }}
                className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"
              >
                {digit}
              </motion.span>
            </div>
          ))}
        </div>
      </div>
      <div className="absolute bottom-4 left-0 w-full px-3">
        <h3 className="text-primary text-sm font-semibold">{cardTitle}</h3>
        <p className="mt-2 text-xs text-neutral-600 dark:text-neutral-400">
          {cardDescription}
        </p>
      </div>
    </motion.div>
  )
}

Installation

npx shadcn@latest add @eldoraui/clerk-otp

Usage

import { ClerkOtp } from "@/components/ui/clerk-otp"
<ClerkOtp />