Input Otp

PreviousNext

A flexible Accessible one-time password component.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/input-otp.tsx
"use client";
import React, {
  useRef,
  useState,
  useEffect,
  createContext,
  useContext,
} from "react";
import { motion } from "framer-motion";
import { MinusIcon } from "lucide-react";
import { cn } from "@/lib/utils";

interface InputOtpContextType {
  values: string[];
  visibleValues: string[];
  handleChange: (val: string, idx: number) => void;
  handleKeyDown: (e: React.KeyboardEvent, idx: number) => void;
  handleFocus: (idx: number) => void;
  handleBlur: (idx: number) => void;
  handlePaste: (e: React.ClipboardEvent, idx: number) => void;
  inputsRef: React.MutableRefObject<(HTMLInputElement | null)[]>;
  mask: boolean;
  maskSymbol: string;
  inputClassName?: string;
}

const InputOtpContext = createContext<InputOtpContextType | null>(null);

interface InputOTPProps {
  maxLength?: number;
  onComplete?: (value: string) => void;
  className?: string;
  containerClassName?: string;
  inputClassName?: string;
  mask?: boolean;
  maskSymbol?: string;
  maskDelay?: number;
  children: React.ReactNode;
}

function InputOTP({
  maxLength = 6,
  onComplete,
  className,
  containerClassName,
  inputClassName,
  mask = false,
  maskSymbol = "*",
  maskDelay = 800,
  children,
}: InputOTPProps) {
  const [values, setValues] = useState(Array(maxLength).fill(""));
  const [visibleValues, setVisibleValues] = useState(Array(maxLength).fill(""));
  const inputsRef = useRef<(HTMLInputElement | null)[]>([]);
  const timeoutsRef = useRef<(NodeJS.Timeout | null)[]>(Array(maxLength).fill(null));

  const clearTimeoutForIndex = (idx: number) => {
    if (timeoutsRef.current[idx]) {
      clearTimeout(timeoutsRef.current[idx]!);
      timeoutsRef.current[idx] = null;
    }
  };

  const applyMaskWithDelay = (idx: number, currentValue: string) => {
    clearTimeoutForIndex(idx);
    if (mask && currentValue) {
      timeoutsRef.current[idx] = setTimeout(() => {
        setVisibleValues((prev) => {
          const updated = [...prev];
          if (updated[idx] !== maskSymbol) {
            updated[idx] = maskSymbol;
          }
          return updated;
        });
      }, maskDelay);
    }
  };

  const handleChange = (val: string, idx: number) => {
    if (val.length > 1) return;

    clearTimeoutForIndex(idx);

    const newValues = [...values];
    const newVisibleValues = [...visibleValues];
    newValues[idx] = val;
    newVisibleValues[idx] = val;
    setValues(newValues);
    setVisibleValues(newVisibleValues);

    if (mask && val) {
      applyMaskWithDelay(idx, val);
    }

    if (val && idx < maxLength - 1) {
      inputsRef.current[idx + 1]?.focus();
    }

    if (newValues.every((v) => v)) {
      onComplete?.(newValues.join(""));
    }
  };

  const handlePaste = (e: React.ClipboardEvent, startIdx: number) => {
    e.preventDefault();
    const pastedText = e.clipboardData.getData("text");

    if (!pastedText) return;

    const newValues = [...values];
    const newVisibleValues = [...visibleValues];

    timeoutsRef.current.forEach((timeout, i) => {
      if (timeout) {
        clearTimeout(timeout);
        timeoutsRef.current[i] = null;
      }
    });

    for (let i = 0; i < pastedText.length && startIdx + i < maxLength; i++) {
      const char = pastedText[i];
      newValues[startIdx + i] = char;
      newVisibleValues[startIdx + i] = char;
    }

    setValues(newValues);
    setVisibleValues(newVisibleValues);

    if (mask) {
      for (let i = 0; i < pastedText.length && startIdx + i < maxLength; i++) {
        const idx = startIdx + i;
        if (newValues[idx]) {
          timeoutsRef.current[idx] = setTimeout(() => {
            setVisibleValues((prev) => {
              const updated = [...prev];
              if (updated[idx] !== maskSymbol) {
                updated[idx] = maskSymbol;
              }
              return updated;
            });
          }, maskDelay);
        }
      }
    }

    const nextEmptyIndex = newValues.findIndex((v, i) => i > startIdx && !v);
    const focusIndex = nextEmptyIndex !== -1
      ? nextEmptyIndex
      : Math.min(startIdx + pastedText.length, maxLength - 1);

    setTimeout(() => {
      inputsRef.current[focusIndex]?.focus();
    }, 0);

    if (newValues.every((v) => v)) {
      onComplete?.(newValues.join(""));
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent, idx: number) => {
    if (e.key === "Backspace") {
      clearTimeoutForIndex(idx);
      if (!values[idx] && idx > 0) {
        inputsRef.current[idx - 1]?.focus();
      } else {
        const newValues = [...values];
        const newVisibleValues = [...visibleValues];
        newValues[idx] = "";
        newVisibleValues[idx] = "";
        setValues(newValues);
        setVisibleValues(newVisibleValues);
      }
    }
  };

  const handleFocus = (idx: number) => {
    if (mask && values[idx]) {
      clearTimeoutForIndex(idx);
      setVisibleValues((prev) => {
        const updated = [...prev];
        updated[idx] = values[idx];
        return updated;
      });
      applyMaskWithDelay(idx, values[idx]);
    }
  };

  const handleBlur = (idx: number) => {
    if (mask && values[idx]) {
      clearTimeoutForIndex(idx);
      setVisibleValues((prev) => {
        const updated = [...prev];
        updated[idx] = maskSymbol;
        return updated;
      });
    }
  };

  useEffect(() => {
    const currentTimeouts = timeoutsRef.current;

    return () => {
      currentTimeouts.forEach((timeout) => {
        if (timeout) clearTimeout(timeout);
      });
    };
  }, []);

  const contextValue: InputOtpContextType = {
    values,
    visibleValues,
    handleChange,
    handleKeyDown,
    handleFocus,
    handleBlur,
    handlePaste,
    inputsRef,
    mask,
    maskSymbol,
    inputClassName,
  };

  return (
    <InputOtpContext.Provider value={contextValue}>
      <div
        data-slot="input-otp"
        className={cn(
          "flex items-center gap-1 sm:gap-2 has-disabled:opacity-50",
          containerClassName
        )}
      >
        <div className={cn("flex items-center gap-1 sm:gap-2", className)}>
          {children}
        </div>
      </div>
    </InputOtpContext.Provider>
  );
}

function InputOTPGroup({
  className,
  children,
  ...props
}: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="input-otp-group"
      className={cn(
        "flex items-center gap-1 sm:gap-2 px-1 py-0.5 rounded-md sm:rounded-lg",
        "bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10",
        className
      )}
      {...props}
    >
      {children}
    </div>
  );
}

function InputOTPSlot({
  index,
  className,
  ...props
}: Omit<React.ComponentProps<"div">, "children"> & {
  index: number;
}) {
  const context = useContext(InputOtpContext);

  if (!context) {
    throw new Error("InputOTPSlot must be used within InputOTP");
  }

  const {
    visibleValues,
    handleChange,
    handleKeyDown,
    handleFocus,
    handleBlur,
    handlePaste,
    inputsRef,
    mask,
    maskSymbol,
    inputClassName,
  } = context;

  return (
    <motion.div
      className="relative"
      initial={{ scale: 1 }}
      whileFocus={{ scale: 1.05 }}
      whileHover={{ scale: 1.02 }}
    >
      <input
        ref={(el) => (inputsRef.current[index] = el)}
        type="text"
        inputMode="text"
        maxLength={1}
        value={visibleValues[index]}
        onChange={(e) => handleChange(e.target.value, index)}
        onKeyDown={(e) => handleKeyDown(e, index)}
        onFocus={() => handleFocus(index)}
        onBlur={() => handleBlur(index)}
        onPaste={(e) => handlePaste(e, index)}
        className={cn(
          "w-8 h-10 sm:w-10 sm:h-12 md:w-12 md:h-14",
          "rounded-lg sm:rounded-xl text-center font-semibold outline-none transition-all duration-200",
          "border border-transparent bg-white/60 dark:bg-white/10 shadow-inner",
          "focus:ring-2 focus:ring-primary/70 dark:focus:ring-primary/40 focus:border-primary/30",
          "backdrop-blur-md text-black dark:text-white placeholder-transparent",
          visibleValues[index] === maskSymbol
            ? "text-lg sm:text-xl md:text-2xl"
            : "text-sm sm:text-base md:text-lg",
          "font-mono",
          inputClassName,
          className
        )}
      />
      <motion.div
        layoutId={`glow-${index}`}
        className="absolute inset-0 rounded-lg sm:rounded-xl pointer-events-none"
        style={{ boxShadow: "0 0 4px 1px rgba(0,0,0,0.06)" }}
      />
    </motion.div>
  );
}

function InputOTPSeparator({
  separatorSymbol,
  className,
  ...props
}: React.ComponentProps<"div"> & {
  separatorSymbol?: React.ReactNode;
}) {
  return (
    <div
      data-slot="input-otp-separator"
      role="separator"
      className={cn("flex items-center justify-center", className)}
      {...props}
    >
      {separatorSymbol || <MinusIcon className="w-3 h-3 sm:w-4 sm:h-4" />}
    </div>
  );
}

export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

Installation

npx shadcn@latest add @scrollxui/input-otp

Usage

import { InputOtp } from "@/components/input-otp"
<InputOtp />