Search

Ctrl + K

OtpInput

This document outlines the steps to create OtpInput component styled withTailwind CSS and using some npm dependency libraries.

Prerequisites

  • Node.js and npm installed on your machine.
  • Tailwind CSS installed in your project.
  • CVA(class-variance-authority) is a utility for managing CSS class names based on various conditions.
  • clsx is a tiny utility for constructing className strings conditionally.
Step 1: Create OtpInput component
1// otp-input.component.tsx
2import clsx from "clsx";
3import { Fragment, useEffect, useRef, useState } from "react";
4
5export interface OtpInputProps {
6  total?: number;
7  separator?: React.ReactNode;
8  className?: string;
9  separatorClassName?: string;
10  inputClassName?: string;
11  onFinish?: (otp: string) => void;
12}
13
14const OtpInput: React.FC<OtpInputProps> = ({
15  total = 5,
16  separator,
17  className,
18  separatorClassName,
19  inputClassName,
20  onFinish,
21}) => {
22  const [otp, setOtp] = useState<number[]>([]);
23
24  const refs = useRef<HTMLInputElement[]>([]);
25  const focusingIndex = useRef<number | null>(null);
26
27  const focusInput = (index: number) => {
28    refs.current[index]?.select();
29  };
30
31  useEffect(() => {
32    const handlePress = (e: KeyboardEvent) => {
33      if (focusingIndex.current === null) return;
34
35      if (e.key === "ArrowLeft") {
36        const newIndex = (focusingIndex.current - 1 + total) % total;
37        focusInput(newIndex);
38      } else if (e.key === "ArrowRight") {
39        const newIndex = (focusingIndex.current + 1) % total;
40        focusInput(newIndex);
41      } else if (e.key === "Enter") {
42        if (focusingIndex.current === null) return;
43        refs.current[focusingIndex.current]?.blur();
44        const otpString = otp.join("");
45        onFinish?.(otpString);
46      } else if (e.key === "Backspace" || e.key === "Delete") {
47        if (focusingIndex.current === null) return;
48        setOtp((otp) => otp.filter((_, idx) => idx !== focusingIndex.current));
49        setTimeout(() => {
50          const newIndex =
51            focusingIndex.current! > 0 ? focusingIndex.current! - 1 : 0;
52          focusInput(newIndex);
53        });
54      }
55    };
56
57    document.addEventListener("keydown", handlePress);
58
59    return () => {
60      document.removeEventListener("keydown", handlePress);
61    };
62
63    // eslint-disable-next-line react-hooks/exhaustive-deps
64  }, [total, otp]);
65
66  const handleInput = (
67    e: React.KeyboardEvent<HTMLInputElement>,
68    index: number,
69  ) => {
70    e.preventDefault();
71
72    const value = e.key;
73    if (!/d/.test(value)) return;
74
75    const code = Number(value);
76
77    const newOtp =
78      index <= otp.length - 1
79        ? otp.map((item, idx) => (idx === index ? code : item))
80        : [...otp, Number(value)].slice(-total);
81    setOtp(newOtp);
82
83    if (
84      focusingIndex.current !== null &&
85      focusingIndex.current < newOtp.length - 1
86    ) {
87      const newIndex = focusingIndex.current + 1;
88      focusInput(newIndex);
89    } else if (newOtp.length === total) {
90      refs.current[total - 1]?.blur();
91      onFinish?.(newOtp.join(""));
92    } else if (newOtp.length < total) {
93      const newIndex = newOtp.length;
94      focusInput(newIndex);
95    }
96  };
97
98  return (
99    <div className={clsx("flex items-center", className)}>
100      {Array.from({ length: total }).map((_, index) => (
101        <Fragment key={index}>
102          {index > 0 && (
103            <div
104              className={clsx("mx-2 text-2xl font-semibold", separatorClassName)}
105            >
106              {separator}
107            </div>
108          )}
109          <input
110            ref={(ref) => {
111              refs.current[index] = ref as HTMLInputElement;
112            }}
113            type="text"
114            maxLength={1}
115            value={otp[index] || ""}
116            className={clsx(
117              "h-12 w-12 rounded-md border border-gray-300 text-center text-2xl font-semibold dark:border-gray-700",
118              inputClassName,
119            )}
120            onKeyDown={(e) => handleInput(e, index)}
121            onChange={() => {}}
122            onFocus={() => (focusingIndex.current = index)}
123            onBlur={() => (focusingIndex.current = null)}
124          />
125        </Fragment>
126      ))}
127    </div>
128  );
129};
130
131export default OtpInput;
132