Pin Input
Categorystyled
ArchitectureReact
Client
Component
Dependencies
Usage
Simple Demo
Controlled Demo
Please enter your 2FA
DO NOT ENTER A VALID CODE
Sending the code...
Install Instructions
Copy and paste the code into that file
import {
type ClipboardEventHandler,
type ComponentPropsWithoutRef,
type KeyboardEventHandler,
type ReactNode,
type Dispatch,
type SetStateAction,
useCallback,
useMemo,
useRef,
useEffect,
} from "react";
//----- Third-Party Utils -----//
import { cva } from "class-variance-authority";
import { twMerge } from "tailwind-merge";
//----- Exported Interfaces -----//
export interface PinInputProps
extends Omit<
ComponentPropsWithoutRef<"input">,
"type" | "length" | "onKeyDown" | "invalid" | "onError"
> {
value?: string;
setValue?: Dispatch<SetStateAction<string>>;
count: number;
type?: RegExp | "number" | "string";
onError?: (type: "lengthMissMatch" | "invalidChars") => void;
onComplete?: (v: string) => void;
invalid?: boolean;
valid?: boolean;
wrapperClassName?: string;
}
//----- Internal Utils -----//
const stringOnlyRegExp = /^[A-Za-z]+$/;
const numOnlyRegExp = /^[0-9]+$/;
const verifyString = (ch: string, type: PinInputProps["type"]) => {
switch (type) {
case undefined:
return true;
case "number":
return numOnlyRegExp.test(ch);
case "string":
return stringOnlyRegExp.test(ch);
default: {
try {
return type.test(ch);
} catch (err) {
console.error(err);
return false;
}
}
}
};
/** Focusses on the next suitable input element */
const focusNextInput = (wrapper: HTMLElement, to: "previous" | "next") => {
const inputs = wrapper.querySelectorAll("input");
let targetEl: HTMLInputElement | null = null;
for (let i = 0; i < inputs.length; i++) {
const input = inputs.item(i);
if (input.value === "") {
targetEl =
to === "next"
? (input as HTMLInputElement | null)
: i === 0
? input
: (input.previousElementSibling as HTMLInputElement | null);
break;
}
}
if (targetEl !== null) {
targetEl.focus();
} else {
inputs.item(inputs.length - 1)?.blur();
}
};
/** Determines if every input element is filled with data */
const isFilled = (wrapper: HTMLElement) => {
const inputs = wrapper.querySelectorAll("input");
let filled = true;
for (let i = 0; i < inputs.length; i++) {
const input = inputs.item(i);
if (input.value === "") {
filled = false;
break;
}
}
return filled;
};
//----- Styles -----//
const inputClassName = cva([
"p-2",
"border",
"border-transparent",
"text-xs",
"overflow-hidden",
"block",
"text-center",
"aspect-square",
"h-8",
"dark:bg-neutral-800",
"bg-neutral-200",
"text-neutral-900",
"dark:text-neutral-100",
"rounded-lg",
"outline-none",
"transition-[border]",
"dark:data-[valid=true]:border-green-300",
"dark:data-[invalid=true]:border-red-300",
"data-[valid=true]:border-green-500",
"data-[invalid=true]:border-red-500",
"dark:hover:[&:not(:read-only):not(:focus-within)]:border-primary-300/50",
"dark:focus-within:[&:not(:read-only)]:border-primary-300",
"hover:[&:not(:read-only):not(:focus-within)]:border-primary-500/50",
"focus-within:[&:not(:read-only)]:border-primary-500",
"read-only:cursor-default",
"read-only:text-opacity-50",
"dark:read-only:text-opacity-50",
"read-only:select-none",
]);
//----- PinInput Component -----//
export const PinInput = ({
count,
disabled,
type,
invalid,
className = "",
wrapperClassName = "",
onError,
onComplete,
readOnly,
valid,
value,
setValue,
...props
}: PinInputProps): ReactNode => {
const localValueRef = useRef("");
const completedSetupRef = useRef(false);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const touched = useRef(false);
const itemsMapped = useMemo(() => new Array(count).fill(null), [count]);
/** Populates the input fields */
const populate = useCallback(
(value: string, focus = true) => {
const { current: wrapper } = wrapperRef;
if (wrapper === null) return;
const inputs = wrapper.querySelectorAll("input");
inputs.forEach((input, i) => {
input.value = typeof value[i] !== "undefined" ? value[i]! : "";
});
// Incase this input isn't the last one
focus && focusNextInput(wrapper, "next");
if (isFilled(wrapper)) {
onComplete && onComplete(value);
}
},
[onComplete]
);
/** Processes the new characters */
const onTextData = useCallback(
(e: Event) => {
e.preventDefault();
if (disabled || readOnly) return;
// Storing variables
const { data } = e as unknown as { data: string };
// Verifying the `key` with chosen type
if (!verifyString(data, type)) {
onError && onError("invalidChars");
return;
}
// Applying the key to the value incase the input isn't controlled
if (typeof setValue === "undefined") {
localValueRef.current = localValueRef.current + data;
populate(localValueRef.current);
} else {
setValue && setValue((curr) => curr + data);
}
},
[type, disabled, readOnly, onError, setValue, populate]
);
/** `keyup` event to delete content of the input elements */
const onKeyUp = useCallback<KeyboardEventHandler>(
(e) => {
const { key } = e;
// Incase the key is a `Backspace`
if (key === "Backspace") {
if (typeof setValue === "undefined") {
localValueRef.current = localValueRef.current.slice(0, -1);
populate(localValueRef.current);
} else {
setValue((curr) => curr.slice(0, -1));
}
focusNextInput(e.target as HTMLInputElement, "previous");
return;
}
},
[populate, setValue]
);
/** Applies event listeners to the input elements */
const setupListeners = useCallback(() => {
const { current: wrapper } = wrapperRef;
if (completedSetupRef.current || wrapper === null) return;
const inputs = wrapper.querySelectorAll("input");
inputs.forEach((input) => {
input.addEventListener("textInput", onTextData);
});
}, [onTextData]);
/** Removes event listeners from the input elements */
const clearListeners = useCallback(() => {
const { current: wrapper } = wrapperRef;
if (completedSetupRef.current || wrapper === null) return;
const inputs = wrapper.querySelectorAll("input");
inputs.forEach((input) => {
input.removeEventListener("textInput", onTextData);
});
}, [onTextData]);
/** Handles the `onpaste` event and incase the pasted data is valid calls the `onValue` function */
const onPaste = useCallback<ClipboardEventHandler<HTMLInputElement>>(
(e) => {
e.preventDefault();
if (disabled || readOnly) return;
const data = e.clipboardData.getData("text");
// Verifying the length
if (data.length !== count) {
onError && onError("lengthMissMatch");
return;
}
// Verifying the chars
if (!verifyString(data, type)) {
onError && onError("invalidChars");
return;
}
if (typeof setValue !== "undefined") {
setValue(data);
} else {
populate(data);
}
},
[readOnly, disabled, count, onError, populate, setValue, type]
);
/** Hydrating the inputs from outside state */
useEffect(() => {
typeof value !== "undefined" && populate(value, touched.current);
}, [populate, value]);
/** Manages the event listeners */
useEffect(() => {
if (count !== 0) setupListeners();
return () => {
clearListeners();
};
}, [setupListeners, clearListeners, count]);
return (
<div
ref={wrapperRef}
className={twMerge(
"flex justify-center items-center content-center gap-2",
wrapperClassName
)}
>
{itemsMapped.map((_, i) => (
<input
onFocus={() => (touched.current = true)}
onPaste={onPaste}
onKeyUp={onKeyUp}
className={twMerge(inputClassName(), className)}
key={i}
data-last-child={i === count - 1}
data-first-child={i === 0}
data-invalid={invalid}
data-valid={valid}
readOnly={readOnly}
disabled={disabled}
{...props}
/>
))}
</div>
);
};