Pin Input

A modular, style-agnostic PinInput component for React, ready to use with just a copy-paste—no extra libraries needed.

ForkGitHub

Two-factor Authentication

Please enter the authentication code.
We have sent the authentication code to your email.

Get Started

  1. Copy & paste pin-input.tsx into your project.
  2. Use the component as needed, referring to the usage section for guidance.

Features

  • No additional library required.
  • Supports both controlled and uncontrolled components.
  • Compatible with form libraries.
  • Simple & Accessible

Note

You don't need to install a component or CSS library, but you can use one to enhance and customize the component.

Snippets

import * as React from "react";
import { cn } from "@/lib/utils";

interface PinInputProps {
  children:
    | React.ReactElement<typeof PinInputField>
    | React.ReactElement<typeof PinInputField>[];
  /**
   * className for the input container
   */
  className?: string;
  /**
   * `aria-label` for the input fields
   */
  ariaLabel?: string;
  /**
   * If set, the pin input receives focus on mount, `false` by default
   */
  autoFocus?: boolean;
  /**
   * Called when value changes
   */
  onChange?: (value: string) => void;
  /**
   * Called when all inputs have valid value
   */
  onComplete?: (value: string) => void;
  /**
   * Called when any input doesn't have value
   */
  onIncomplete?: (value: string) => void;
  /**
   * `name` attribute for input fields
   */
  name?: string;
  /**
   * `form` attribute for hidden input
   */
  form?: string;
  /**
   * If set, the input's value will be masked just like password input. This field is `false` by default
   */
  mask?: boolean;
  /**
   * If set, the pin input component signals to its fields that they should
   * use `autocomplete="one-time-code"`. This field is `false` by default
   */
  otp?: boolean;
  /**
   * Uncontrolled pin input default value.
   */
  defaultValue?: string;
  /**
   * Controlled pin input value.
   */
  value?: string;
  /**
   * The type of value pin input should allow, `alphanumeric` by default
   */
  type?: "numeric" | "alphanumeric";
  /**
   * Placeholder for input fields, `○` by default
   */
  placeholder?: string;
  /**
   * If set, the user cannot set the value, `false` by default
   */
  readOnly?: boolean;
  /**
   * If set, the input fields are disabled, `false` by default
   */
  disabled?: boolean;
}

const PinInputContext = React.createContext<boolean>(false);

const PinInput = React.forwardRef<HTMLDivElement, PinInputProps>(
  ({ className, children, ...props }, ref) => {
    const {
      defaultValue,
      value,
      onChange,
      onComplete,
      onIncomplete,
      placeholder = "○",
      type = "alphanumeric",
      name,
      form,
      otp = false,
      mask = false,
      disabled = false,
      readOnly = false,
      autoFocus = false,
      ariaLabel = "",
      ...rest
    } = props;

    const validChildren = getValidChildren(children);

    const length = getInputFieldCount(children);

    // pins, pinValue, refMap, ...handlers
    const { pins, pinValue, refMap, ...handlers } = usePinInput({
      value,
      defaultValue,
      placeholder,
      type,
      length,
      readOnly,
    });

    /* call onChange func if pinValue changes */
    React.useEffect(() => {
      if (!onChange) return
      onChange(pinValue)
    }, [onChange, pinValue]);

    /* call onComplete/onIncomplete func if pinValue is valid and completed/incompleted */
    const completeRef = React.useRef(pinValue.length === length);
    React.useEffect(() => {
      if (pinValue.length === length && completeRef.current === false) {
        completeRef.current = true;
        if (onComplete) onComplete(pinValue);
      }
      if (pinValue.length !== length && completeRef.current === true) {
        completeRef.current = false;
        if (onIncomplete) onIncomplete(pinValue);
      }
    }, [length, onComplete, onIncomplete, pinValue, pins, value]);

    /* focus on first input field if autoFocus is set */
    React.useEffect(() => {
      if (!autoFocus) return;
      const node = refMap?.get(0);
      if (node) {
        node.focus();
      }
    }, [autoFocus, refMap]);

    const skipRef = React.useRef(0);
    let counter = 0;
    const clones = validChildren.map((child) => {
      if (child.type === PinInputField) {
        const pinIndex = counter;
        counter = counter + 1;
        return React.cloneElement(child, {
          name,
          inputKey: `input-${pinIndex}`,
          value: length > pinIndex ? pins[pinIndex] : "",
          onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
            handlers.handleChange(e, pinIndex),
          onFocus: (e: React.FocusEvent<HTMLInputElement>) =>
            handlers.handleFocus(e, pinIndex),
          onBlur: () => handlers.handleBlur(pinIndex),
          onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) =>
            handlers.handleKeyDown(e, pinIndex),
          onPaste: (e: React.ClipboardEvent<HTMLInputElement>) =>
            handlers.handlePaste(e),
          placeholder: placeholder,
          type: type,
          mask: mask,
          autoComplete: otp ? "one-time-code" : "off",
          disabled: disabled,
          readOnly: readOnly,
          "aria-label": ariaLabel
            ? ariaLabel
            : `Pin input ${counter} of ${length}`,
          ref: (node: HTMLInputElement | null) => {
            if (node) {
              refMap?.set(pinIndex, node);
            } else {
              refMap?.delete(pinIndex);
            }
          },
        });
      }
      skipRef.current = skipRef.current + 1;
      return child;
    });

    return (
      <PinInputContext.Provider value={true}>
        <div ref={ref} aria-label="Pin Input" className={className} {...rest}>
          {clones}
          <input type="hidden" name={name} form={form} value={pinValue} />
        </div>
      </PinInputContext.Provider>
    );
  }
);
PinInput.displayName = "PinInput";

/* ========== PinInputField ========== */

interface _PinInputFieldProps {
  mask: boolean;
  inputKey: string;
  type: "numeric" | "alphanumeric";
}

interface PinInputFieldProps<T>
  extends Omit<
    React.ComponentPropsWithoutRef<"input">,
    keyof _PinInputFieldProps
  > {
  component?: T;
}

const PinInputFieldNoRef = <T extends React.ElementType = "input">(
  {
    className,
    component,
    ...props
  }: PinInputFieldProps<T> &
    (React.ComponentType<T> extends undefined
      ? never
      : React.ComponentProps<T>),
  ref: React.ForwardedRef<HTMLInputElement>
) => {
  const { mask, type, inputKey, ...rest } = props as _PinInputFieldProps &
    React.ComponentProps<T>;

  // Check if PinInputField is used within PinInput
  const isInsidePinInput = React.useContext(PinInputContext);
  if (!isInsidePinInput) {
    throw new Error(
      `PinInputField must be used within ${PinInput.displayName}.`
    );
  }

  const Element = component || "input";

  return (
    <Element
      key={inputKey}
      ref={ref}
      type={mask ? "password" : type === "numeric" ? "tel" : "text"}
      inputMode={type === "numeric" ? "numeric" : "text"}
      className={cn("size-10 text-center", className)}
      {...rest}
    />
  );
};

const PinInputField = React.forwardRef(PinInputFieldNoRef) as <
  T extends React.ElementType = "input"
>(
  {
    className,
    component,
    ...props
  }: PinInputFieldProps<T> & React.ComponentProps<T>,
  ref: React.ForwardedRef<HTMLInputElement>
) => JSX.Element;

/* ========== usePinInput custom hook ========== */

interface UsePinInputProps {
  value: string | undefined;
  defaultValue: string | undefined;
  placeholder: string;
  type: "numeric" | "alphanumeric";
  length: number;
  readOnly: boolean;
}

const usePinInput = ({
  value,
  defaultValue,
  placeholder,
  type,
  length,
  readOnly,
}: UsePinInputProps) => {
  const pinInputs = React.useMemo(
    () =>
      Array.from({ length }, (_, index) =>
        defaultValue
          ? defaultValue.charAt(index)
          : value
          ? value.charAt(index)
          : ""
      ),
    [defaultValue, length, value]
  );

  const [pins, setPins] = React.useState<(string | number)[]>(pinInputs);
  const pinValue = pins.join("").trim();

  /**
   * Update pins when values changes.
   * This is necessary and this allows pins to be synced
   * when the value is update or reset programmatically
   */
  React.useEffect(() => {
    setPins(pinInputs);
  }, [pinInputs]);

  const itemsRef = React.useRef<Map<number, HTMLInputElement> | null>(null);

  function getMap() {
    if (!itemsRef.current) {
      // Initialize the Map on first usage.
      itemsRef.current = new Map();
    }
    return itemsRef.current;
  }

  function getNode(index: number) {
    const map = getMap();
    const node = map?.get(index);
    return node;
  }

  function focusInput(itemId: number) {
    const node = getNode(itemId);
    if (node) {
      node.focus();
      node.placeholder = "";
    }
  }

  function handleFocus(
    event: React.FocusEvent<HTMLInputElement>,
    index: number
  ) {
    event.target.select();
    focusInput(index);
  }

  function handleBlur(index: number) {
    const node = getNode(index);
    if (node) {
      node.placeholder = placeholder;
    }
  }

  function updateInputField(val: string, index: number) {
    const node = getNode(index);

    if (node) {
      node.value = val;
    }

    setPins((prev) =>
      prev.map((p, i) => {
        if (i === index) {
          return val;
        } else {
          return p;
        }
      })
    );
  }

  function validate(value: string) {
    const NUMERIC_REGEX = /^[0-9]+$/;
    const ALPHA_NUMERIC_REGEX = /^[a-zA-Z0-9]+$/i;
    const regex = type === "alphanumeric" ? ALPHA_NUMERIC_REGEX : NUMERIC_REGEX;
    return regex.test(value);
  }

  const pastedVal = React.useRef<null | string>(null);
  function handleChange(e: React.ChangeEvent<HTMLInputElement>, index: number) {
    const inputValue = e.target.value;
    const pastedValue = pastedVal.current;
    const inputChar =
      pastedValue && pastedValue.length === length
        ? pastedValue.charAt(length - 1)
        : inputValue.slice(-1);

    if (validate(inputChar)) {
      updateInputField(inputChar, index);
      pastedVal.current = null;
      if (inputValue.length > 0) {
        focusInput(index + 1);
      }
    }
  }

  function handlePaste(event: React.ClipboardEvent<HTMLInputElement>) {
    event.preventDefault();
    const copyValue = event.clipboardData
      .getData("text/plain")
      .replace(/[\n\r\s]+/g, '')
    const copyArr = copyValue.split("").slice(0, length);

    const isValid = copyArr.every((c) => validate(c));

    if (!isValid) return;

    for (let i = 0; i < length; i++) {
      if (i < copyArr.length) {
        updateInputField(copyArr[i], i);
      }
    }

    pastedVal.current = copyValue;
    focusInput(copyArr.length < length ? copyArr.length : length - 1);
  }

  function handleKeyDown(
    event: React.KeyboardEvent<HTMLInputElement>,
    index: number
  ) {
    const { ctrlKey, key, shiftKey, metaKey } = event;

    if (type === "numeric") {
      const canTypeSign =
        key === "Backspace" ||
        key === "Tab" ||
        key === "Control" ||
        key === "Delete" ||
        (ctrlKey && key === "v") ||
        (metaKey && key === "v")
          ? true
          : !Number.isNaN(Number(key));

      if (!canTypeSign || readOnly) {
        event.preventDefault();
      }
    }

    if (key === "ArrowLeft" || (shiftKey && key === "Tab")) {
      event.preventDefault();
      focusInput(index - 1);
    } else if (key === "ArrowRight" || key === "Tab" || key === " ") {
      event.preventDefault();
      focusInput(index + 1);
    } else if (key === "Delete") {
      event.preventDefault();
    } else if (key === "Backspace") {
      event.preventDefault();
      updateInputField("", index);
      if ((event.target as HTMLInputElement).value === "") {
        focusInput(index - 1);
      }
    }
  }

  return {
    pins,
    pinValue,
    refMap: getMap(),
    handleFocus,
    handleBlur,
    handleChange,
    handlePaste,
    handleKeyDown,
  };
};

/* ========== Util Func ========== */

const getValidChildren = (children: React.ReactNode) =>
  React.Children.toArray(children).filter((child) => {
    if (React.isValidElement(child)) {
      return React.isValidElement(child);
    }
    throw new Error(`${PinInput.displayName} contains invalid children.`);
  }) as React.ReactElement[];

const getInputFieldCount = (children: React.ReactNode) =>
  React.Children.toArray(children).filter((child) => {
    if (React.isValidElement(child) && child.type === PinInputField) {
      return React.isValidElement(child);
    }
  }).length;

export { PinInput, PinInputField };

Usage