animated-o-t-p-input

PreviousNext

A AnimatedOTPInput component for SmoothUI.

Docs
smoothuiui

Preview

Loading preview…
index.tsx
"use client";

import { cn } from "@repo/shadcn-ui/lib/utils";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { type ReactNode, useContext, useEffect, useState } from "react";

// Animation constants
const EASE_OUT_QUINT_X1 = 0.22;
const EASE_OUT_QUINT_Y1 = 1;
const EASE_OUT_QUINT_X2 = 0.36;
const EASE_OUT_QUINT_Y2 = 1;
const EASE_OUT_QUINT = [
  EASE_OUT_QUINT_X1,
  EASE_OUT_QUINT_Y1,
  EASE_OUT_QUINT_X2,
  EASE_OUT_QUINT_Y2,
] as const;
const ANIMATION_DURATION_SHORT = 0.1;
const ANIMATION_DURATION_MEDIUM = 0.15;
const ANIMATION_DURATION_STANDARD = 0.2;
const ANIMATION_DURATION_LONG = 0.3;
const STAGGER_DELAY = 0.05;
const SCALE_FILLED = 1.05;
const SCALE_HOVER = 1.02;
const SCALE_TAP = 0.98;
const INITIAL_SCALE = 0.8;
const INITIAL_Y = 10;
const SEPARATOR_DELAY = 0.15;

export type AnimatedInputOTPProps = {
  containerClassName?: string;
  value?: string;
  onChange?: (value: string) => void;
  onComplete?: (value: string) => void;
  maxLength?: number;
  className?: string;
};

function AnimatedInputOTP({
  className,
  containerClassName,
  value,
  onChange,
  onComplete,
  maxLength = 6,
  children,
  ...props
}: AnimatedInputOTPProps & { children: ReactNode }) {
  const handleChange = (newValue: string) => {
    // Only allow numeric characters
    const numericValue = newValue.replace(/[^0-9]/g, "");
    onChange?.(numericValue);
  };

  return (
    <OTPInput
      className={cn("disabled:cursor-not-allowed", className)}
      containerClassName={cn(
        "flex items-center gap-2 has-disabled:opacity-50",
        containerClassName
      )}
      data-slot="input-otp"
      maxLength={maxLength}
      onChange={handleChange}
      onComplete={onComplete}
      value={value}
      {...props}
    >
      {children}
    </OTPInput>
  );
}

type AnimatedInputOTPGroupProps = {
  className?: string;
  children?: ReactNode;
};

function AnimatedInputOTPGroup({
  className,
  children,
}: AnimatedInputOTPGroupProps) {
  return (
    <motion.div
      animate={{ opacity: 1, y: 0 }}
      className={cn("flex items-center", className)}
      data-slot="input-otp-group"
      initial={{ opacity: 0, y: INITIAL_Y }}
      transition={{
        duration: ANIMATION_DURATION_LONG,
        ease: EASE_OUT_QUINT,
      }}
    >
      {children}
    </motion.div>
  );
}

type AnimatedInputOTPSlotProps = {
  index: number;
  className?: string;
};

function AnimatedInputOTPSlot({ index, className }: AnimatedInputOTPSlotProps) {
  const inputOTPContext = useContext(OTPInputContext);
  const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
  const [isFilled, setIsFilled] = useState(false);

  useEffect(() => {
    if (char && !isFilled) {
      setIsFilled(true);
    } else if (!char && isFilled) {
      setIsFilled(false);
    }
  }, [char, isFilled]);

  return (
    <motion.div
      animate={{
        opacity: 1,
        y: 0,
        scale: isFilled ? SCALE_FILLED : 1,
      }}
      className={cn(
        "relative flex h-9 w-9 items-center justify-center border-input border-y border-r text-sm shadow-xs outline-none transition-all first:rounded-l-md first:border-l last:rounded-r-md aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:border-ring data-[active=true]:ring-[3px] data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:border-destructive data-[active=true]:aria-invalid:ring-destructive/20 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
        className
      )}
      data-active={isActive}
      data-slot="input-otp-slot"
      initial={{ opacity: 0, scale: INITIAL_SCALE, y: INITIAL_Y }}
      transition={{
        duration: ANIMATION_DURATION_STANDARD,
        delay: index * STAGGER_DELAY,
        ease: EASE_OUT_QUINT,
        scale: {
          duration: ANIMATION_DURATION_MEDIUM,
          ease: EASE_OUT_QUINT,
        },
      }}
      whileHover={{
        scale: SCALE_HOVER,
        transition: {
          duration: ANIMATION_DURATION_MEDIUM,
          ease: EASE_OUT_QUINT,
        },
      }}
      whileTap={{
        scale: SCALE_TAP,
        transition: {
          duration: ANIMATION_DURATION_SHORT,
          ease: EASE_OUT_QUINT,
        },
      }}
    >
      <AnimatePresence mode="wait">
        {char && (
          <motion.span
            animate={{ opacity: 1, scale: 1, rotateY: 0 }}
            className="font-medium"
            exit={{ opacity: 0, scale: 0.5, rotateY: 90 }}
            initial={{ opacity: 0, scale: 0.5, rotateY: -90 }}
            key={char}
            transition={{
              duration: ANIMATION_DURATION_STANDARD,
              ease: EASE_OUT_QUINT,
            }}
          >
            {char}
          </motion.span>
        )}
      </AnimatePresence>

      {hasFakeCaret && (
        <motion.div
          animate={{ opacity: 1 }}
          className="pointer-events-none absolute inset-0 flex items-center justify-center"
          exit={{ opacity: 0 }}
          initial={{ opacity: 0 }}
          transition={{ duration: ANIMATION_DURATION_SHORT }}
        >
          <motion.div
            animate={{ opacity: [0, 1, 0] }}
            className="h-4 w-px bg-foreground"
            transition={{
              duration: 1,
              repeat: Number.POSITIVE_INFINITY,
              ease: "easeInOut",
            }}
          />
        </motion.div>
      )}
    </motion.div>
  );
}

function AnimatedInputOTPSeparator() {
  return (
    <motion.div
      animate={{ opacity: 1, scale: 1 }}
      data-slot="input-otp-separator"
      initial={{ opacity: 0, scale: INITIAL_SCALE }}
      transition={{
        duration: ANIMATION_DURATION_LONG,
        delay: SEPARATOR_DELAY,
        ease: EASE_OUT_QUINT,
      }}
    >
      <MinusIcon className="h-4 w-4 text-muted-foreground" />
    </motion.div>
  );
}

// Main component that combines everything
export function AnimatedOTPInput({
  maxLength = 6,
  className,
  value,
  onChange,
  onComplete,
  ...props
}: AnimatedInputOTPProps) {
  return (
    <AnimatedInputOTP
      className={className}
      maxLength={maxLength}
      onChange={onChange}
      onComplete={onComplete}
      value={value}
      {...props}
    >
      <AnimatedInputOTPGroup>
        <AnimatedInputOTPSlot index={0} />
        <AnimatedInputOTPSlot index={1} />
        <AnimatedInputOTPSlot index={2} />
      </AnimatedInputOTPGroup>
      <AnimatedInputOTPSeparator />
      <AnimatedInputOTPGroup>
        <AnimatedInputOTPSlot index={3} />
        <AnimatedInputOTPSlot index={4} />
        <AnimatedInputOTPSlot index={5} />
      </AnimatedInputOTPGroup>
    </AnimatedInputOTP>
  );
}

export {
  AnimatedInputOTP,
  AnimatedInputOTPGroup,
  AnimatedInputOTPSlot,
  AnimatedInputOTPSeparator,
};

export default AnimatedOTPInput;

Installation

npx shadcn@latest add @smoothui/animated-o-t-p-input

Usage

import { AnimatedOTPInput } from "@/components/ui/animated-o-t-p-input"
<AnimatedOTPInput />