Todos os componentes

Animated O T P Input

Animated O T P Input é um componente de formulário para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

SmoothUI MIT forms

O que é

Animated O T P Input é um componente de formulário para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.

Como usar

Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.

Abrir no catálogo

Cinco cópias grátis por mês. Sem cartão.

Precisa de

npm i motion lucide-react input-otp

Licença

Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.

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;

Componentes parecidos