Pin Input

Categorystyled
ArchitectureReact Client Component
Dependencies

Usage

Simple Demo

Controls

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>
  );
};

Loading...