All components

Animated O T P Input

Animated O T P Input is a React and Tailwind CSS form component from SmoothUI, licensed MIT. Copy it and paste it into your project.

SmoothUI MIT forms

What it is

Animated O T P Input is a React and Tailwind CSS form component from SmoothUI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

Needs

npm i motion lucide-react input-otp

Licence

This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Animated O T P Input

index.tsx
"use client";

import { cn } from "@/lib/utils";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } 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 interface AnimatedInputOTPProps {
  "aria-describedby"?: string;
  "aria-label"?: string;
  className?: string;
  containerClassName?: string;
  maxLength?: number;
  onChange?: (value: string) => void;
  onComplete?: (value: string) => void;
  value?: 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
      aria-describedby={props["aria-describedby"]}
      aria-label={props["aria-label"] || "One-time password input"}
      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>
  );
}

interface AnimatedInputOTPGroupProps {
  children?: ReactNode;
  className?: string;
}

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

interface AnimatedInputOTPSlotProps {
  className?: string;
  index: number;
}

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

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

  return (
    <motion.div
      animate={
        shouldReduceMotion
          ? { opacity: 1 }
          : {
              opacity: 1,
              scale: isFilled ? SCALE_FILLED : 1,
              y: 0,
            }
      }
      className={cn(
        "relative flex h-9 w-9 items-center justify-center rounded-md border border-zinc-300 bg-background text-sm shadow-sm outline-none transition-all 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:border-zinc-700 dark:bg-input/30 dark:data-[active=true]:aria-invalid:ring-destructive/40",
        className
      )}
      data-active={isActive}
      data-slot="input-otp-slot"
      initial={
        shouldReduceMotion
          ? { opacity: 1 }
          : { opacity: 0, scale: INITIAL_SCALE, y: INITIAL_Y }
      }
      transition={
        shouldReduceMotion
          ? { duration: 0 }
          : {
              delay: index * STAGGER_DELAY,
              duration: ANIMATION_DURATION_STANDARD,
              ease: EASE_OUT_QUINT,
              scale: {
                duration: ANIMATION_DURATION_MEDIUM,
                ease: EASE_OUT_QUINT,
              },
            }
      }
      whileHover={
        shouldReduceMotion
          ? {}
          : {
              scale: SCALE_HOVER,
              transition: {
                duration: ANIMATION_DURATION_MEDIUM,
                ease: EASE_OUT_QUINT,
              },
            }
      }
      whileTap={
        shouldReduceMotion
          ? {}
          : {
              scale: SCALE_TAP,
              transition: {
                duration: ANIMATION_DURATION_SHORT,
                ease: EASE_OUT_QUINT,
              },
            }
      }
    >
      <AnimatePresence mode="wait">
        {char ? (
          <motion.span
            animate={
              shouldReduceMotion
                ? { opacity: 1, scale: 1 }
                : { opacity: 1, rotateY: 0, scale: 1 }
            }
            className="font-medium"
            exit={
              shouldReduceMotion
                ? { opacity: 0, transition: { duration: 0 } }
                : { opacity: 0, rotateY: 90, scale: 0.5 }
            }
            initial={
              shouldReduceMotion
                ? { opacity: 1, scale: 1 }
                : { opacity: 0, rotateY: -90, scale: 0.5 }
            }
            key={char}
            transition={
              shouldReduceMotion
                ? { duration: 0 }
                : {
                    duration: ANIMATION_DURATION_STANDARD,
                    ease: EASE_OUT_QUINT,
                  }
            }
          >
            {char}
          </motion.span>
        ) : null}
      </AnimatePresence>

      {hasFakeCaret && !shouldReduceMotion && (
        <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,
              ease: [0.645, 0.045, 0.355, 1],
              repeat: Number.POSITIVE_INFINITY,
            }}
          />
        </motion.div>
      )}
    </motion.div>
  );
}

function AnimatedInputOTPSeparator() {
  const shouldReduceMotion = useReducedMotion();
  return (
    <motion.div
      animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }}
      data-slot="input-otp-separator"
      initial={
        shouldReduceMotion
          ? { opacity: 1 }
          : { opacity: 0, scale: INITIAL_SCALE }
      }
      transition={
        shouldReduceMotion
          ? { duration: 0 }
          : {
              delay: SEPARATOR_DELAY,
              duration: ANIMATION_DURATION_LONG,
              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,
  AnimatedInputOTPSeparator,
  AnimatedInputOTPSlot,
};

export default AnimatedOTPInput;

Similar components