This document outlines the steps to create OtpInput
component styled withTailwind CSS and using some npm dependency libraries.
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.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