DEV Community

Cover image for Custom OTP inputs are dumb. Here is how to build one.
Lorenzo Rivosecchi
Lorenzo Rivosecchi

Posted on

Custom OTP inputs are dumb. Here is how to build one.

As a developer I don't like OTP inputs because they are complicated to build and not clearly defined by a standard.

If I had to choose a single solution to deal with OTPs for the rest of my life I would do this:

<input type="text" autocomplete="one-time-code" />
Enter fullscreen mode Exit fullscreen mode

It's simple, reliable and well integrated with the browser/os.
Someone on Twitter said that whoever implemented the OTP suggestion on Safari deserves a promotion and I agree.

On the other end, One Time Passwords are annoying and confusing to the end users, therefore it might be good to show them a special kind of input to make the experience feel more familiar.

Let's see how we can create a PassCode component in React in less then 200 lines of code.

API Design

I want my component to handle the behavior of an OTP input but allow me to control the styling. Here is how i want to interact with the component:

import { Passcode } from "./passcode";

function OTPInput() {
  return (
    <Passcode>
      <Passcode.Input />
      <Passcode.Input />
      <Passcode.Input />
      <Passcode.Input />
    </Passcode>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why do i want to manually add the input elements?
Because i want to control how many digits are required and also how to style the individual inputs.

Here is how i would apply basic styles with TailwindCSS:

<Passcode className="flex gap-2">
  <Passcode.Input className="w-8 h-8 text-2xl text-center" />
</Passcode>
Enter fullscreen mode Exit fullscreen mode

Let's create the component module now. First we need to establish a way to synchronize the Passcode component with its children.
One solution would be to create a hook that returns utility functions.

const NUMBER_OF_DIGITS = 4;

function OTPInput() {
  const { parentProps, childProps } = usePasscodeState({
    numberOfDigits: NUMBER_OF_DIGITS,
    alphanumeric: true,
  });
  const children = new Array.from({ lenght: NUMBER_OF_DIGITS }).fill(
    (_, index) => <Passcode.Input key={index} {...childProps(index)} />
  );
  return <Passcode {...parentProps}>{children}</Passcode>;
}
Enter fullscreen mode Exit fullscreen mode

This is probably the most idiomatic way to do it, but I would like to avoid wiring everyting up manually.
We can leverage React context + the Children utility to have the same effect, without having to wire up things manually.

Context allows us to pass a props to the Passcode component and read from the child components.
These props will be shared by every input component regardless of their position in the array.

// passcode.tsx
import * as React from "react";

interface ContextValue = {
    alphanumeric?: boolean;
}

const PasscodeContext = React.createContext<ContextValue>({});

export interface PasscodeProps extends ContextValue {
    className?: string;
    children: ReactNode[];
}

export function Passcode(props: PasscodeProps) {
  const { className, children, ...context } = props;

  return (
    <PassCodeContext.Provider value={context}>
      {children}
    </PasscodeContext>
  )
}

export function PasscodeInput() {
    const { alphanumeric } = React.useContext(PasscodeContext)!;
    return <input inputMode={context.alphanumeric ? "text" : "numeric"} />
}
Enter fullscreen mode Exit fullscreen mode

With the Children helper we can pass props that should be different for every input.
For example, let's add a data-index attribute based on the position of the component in the children array.

export function Passcode(props: PasscodeProps) {
  const { className, children, ...context } = props;

  const inputs = React.Children.map((child, index) => {
    const jsx = child as JSX.Element;
    return React.cloneElement<PasscodeInputProps>(jsx, {
        key: index,
        index,
    });
  });

  return (
    <PasscodeContext.Provider value={context}>
      {inputs}
    </PasscodeContext>
  )
}

export interface PasscodeInputProps {
    index?: number;
}

export function PasscodeInput(props: PasscodeInputProps) {
    const context = React.useContext(PasscodeContext)!;

    return (
        <input inputMode={context.alphanumeric ? "text" : "numeric"} data-index={props.index} />
    )
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we are using React.cloneElement to pass additional props to our children.
Essentially, we can avoid doing this:

<Passcode>
  <Passcode index={0} />
  <Passcode index={1} />
  <Passcode index={2} />
  <Passcode index={3} />
</Passcode>
Enter fullscreen mode Exit fullscreen mode

To improve our code we can add a check to see if the passed children is actually a PasscodeInput component.

const inputs = React.Children.map((child, index) => {
  const jsx = child as JSX.Element;
  if (jsx.type !== PasscodeInput) return null;
  return React.cloneElement<PasscodeInputProps>(element, {
    key: index,
    index,
  });
});
Enter fullscreen mode Exit fullscreen mode

Now that we have found a solution to make our API design work, let's implement the behavior of the Passcode component.

Implementation

As mentioned in the intro, the behavior of an OTP input is complicated, let's divide this into chunks.

Storing the code

To track the code input we need to store its value over time.
Let's store the value as an array of strings where each item is a digit of the code:

const [code, setCode] = React.useState<string[]>([]);
Enter fullscreen mode Exit fullscreen mode

Since passcode inputs should have a fixed length, i would modify this a little bit.
Let's extract the number of digits by counting the children:

const numberOfDigits = React.Children.count(children);
const [code, setCode] = React.useState<string>(
  Array.from({ length: numberOfDigits }, () => "")
);
Enter fullscreen mode Exit fullscreen mode

Having defined the number of digits allows us to pre-fill the code with empty string.
This is important because when we assign the digit to the input element we cannot pass it an undefined value, otherwise the component should switch from being uncontrolled to controlled on the first input.

I haven't tested if this is actually needed, but it might be better to prevent the number of children to change between renders. To achieve this we can wrap number of digits in a useState.
The value will always be the one from the first render:

const [numberOfDigits] = React.useState(
  React.Children.count(children);
)
Enter fullscreen mode Exit fullscreen mode

Tracking user input

To track user input we can use the onKeydown event from React. This event will fire for every keystroke, including the Backspace key that will be useful later. Let's define a handler and passit along with the input value.

const handleKeyDown = React.useCallback<KeynoardEventHandler>((event) => {
  /* ... */
});

const inputs = React.Children.map((child, index) => {
  /* ... */
  return React.cloneElement<PasscodeInputProps>(element, {
    /* ... */
    handleKeyDown,
    value: code[index],
  });
});
Enter fullscreen mode Exit fullscreen mode

The PasscodeInput should simply pass both props to the input element:

export interface PasscodeInputProps {
  /* ... */
  onKeydown?: KeyboardEventHandler;
  value?: string;
}

export function PasscodeInput(props: PasscodeInputProps) {
  /* ... */

  return (
    <input
      {/* ... */}
      onKeyDown={props.onKeydown!}
      value={props.value!}
    >
  )
}
Enter fullscreen mode Exit fullscreen mode

Let's dive into the handleKeydown function implementation.
The main responsibility is to read the key pressed and modify the state when necessary.

const handleKeyDown = React.useCallback<KeynoardEventHandler>((event) => {
  const target = event.target as HTMLInputElement;
  const index = Number(target.dataset.index);
  const keyPressed = event.key;

  setCode((prev) => {
    const next = [...prev];
    next[index] = keyPressed;
    return next;
  });
});
Enter fullscreen mode Exit fullscreen mode

Let's handle the backspace key now:

/* ... */
const isBackspace = keypressed === "Backspace";

if (isBackspace) {
  setCode((prev) => {
    const next = [...prev];
    next[index] = "";
    if (index > 0) next[index - 1] = "";
    return next;
  });
} else {
  /* ... */
}
Enter fullscreen mode Exit fullscreen mode

Finally, let's protect us from unwanted keys:

// default US-104-QWERTY keyboard
const invalidKeys = [
  " ",
  "!",
  "#",
  "$",
  "%",
  "&",
  "(",
  ")",
  "*",
  "@",
  "Alt",
  "AltGraph",
  "ArrowDown",
  "ArrowUp",
  "Backspace",
  "CapsLock",
  "Control",
  "Delete",
  "End",
  "Enter",
  "Escape",
  "Fn",
  "Home",
  "Meta",
  "OS",
  "PageDown",
  "PageUp",
  "Shift",
  "Symbol",
  "Tab",
  "^",
];

/* ... */

const handleKeyDown = React.useCallback<KeynoardEventHandler>((event) => {
  if (invalidKeys.includes(event.key)) return;
  /* ... */
});
Enter fullscreen mode Exit fullscreen mode

Managing input focus

OTP inputs are expected to move focus automatically to the next area after a digit is inserted.
We can achieve this by defining a utility function called moveFocus and use it inside our onKeydown handler.

First, let's attach a ref to the wrapping div of the Passcode component:

export function PassCode(props: PassCodeProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  /* ... */

  return (
    <div ref={containerRef}>
      <PasscodeContext.Provider value={context}>
        {inputs}
      </PasscodeContext>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Now, let's define the utility as a function that receives an index number, retrieves the dom node and calls focus on it:

const moveFocus = useCallback((index: number) => {
  const next = containerRef.current?.children[index];
  if (next instanceof HTMLInputElement) next.focus();
});
Enter fullscreen mode Exit fullscreen mode

And now, let's move focus to the next input when a key is inserted, and to the prev element when the key is backspace:

if (isBackspace) {
  moveFocus(index - 1);
  // ...
} else {
  moveFocus(index + 1);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Enabling Copy and Paste

To allow copy and paste we can use the onPaste event from React.
Same as before, we create the callback on the parent and pass it to the children, which in turn should pass it to the input element.

The handlePaste function should grab the text with event.clipboardData and call setCode accordingly:

const handlePaste = useCallback<ClipboardEventHandler>(
  (event) => {
    event.preventDefault();
    const target = event.target as HTMLInputElement;
    const index = Number(target.dataset.index);

    const pasted = event.clipboardData.getData("text");
    const length = pasted.length;

    if (length >= numberOfDigits) {
      moveFocus(numberOfDigits - 1);
      setCode(Array.from(pasted.slice(0, numberOfDigits)));
    } else {
      moveFocus(index + length);
      const next = [...code];
      for (let i = index; i < numberOfDigits; i++) {
        next[i] = pasted[i - index] ?? "";
      }
      setCode(next);
    }
  },
  [setCode, moveFocus, numberOfDigits, code]
);
Enter fullscreen mode Exit fullscreen mode

One thing to not is that in order to make CTRL or CMD+V work we need to make sure that when the combination of keys is pressed, the handleKeydown isn't preventing the default behavior of the browser:

const handleKeyDown = React.useCallback<KeynoardEventHandler>((event) => {
  if (event.metaKey && event.key.toLowerCase() === "v") return;
  event.preventDefault();
});
Enter fullscreen mode Exit fullscreen mode

Not sure what good does it make to call event.preventDefault anyways, but if you call it, make sure it doesn't happen while pasting.

Adding accessibility labels

One reason why custom OTP inputs are dumb is that since the input is split into multiple elements, the screenreader experience tends to be pydantic.
Maybe there is a better solution, here is how I would approach this.

const labels = [
  "First input digit",
  "Second input digit",
  // ...
];

export function PassCodeInput(props: PassCodeInputProps) {
  // ...
  const id = `passcode-input-${props.index}`;
  const label = labels[props.index];

  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} {/* ... */} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

If you wanna go through the accessibility rabbit hole there are more things to consider.
I would probably make sure to add some context on the page to let the user know what these input digits are about.

<h1>Enter verification code</h1>
<Passcode>
  {/* ... */}
</Passcode>
Enter fullscreen mode Exit fullscreen mode

Validating the input

How you validate the input can vary, but overall you could add an onComplete callback to the Passcode component and call it when the onKeyDown or onPaste handlers have set the last digit.

export interface PasscodeProps {
  // ...
  onComplete?: (code: string) => void;
}

export function PassCode(props: PassCodeProps) {
  // ...

  const handleKeyDown = () => {
    ///...
    if (index === numberOfDigits - 1) {
      props.onComplete?.(code.join(""));
    }
  };

  const handlePaste = () => {
    // ...
    if (next.every((digit) => digit !== "")) {
      props.onComplete?.(code.join(""));
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

If validation is async (likely) you can add an isValidating prop to the Passcode component and disable the inputs when is set to true. Allowing you to do something like this on:

import { Passcode } from "./passcode";
import * as React from "react";

async function validate(code: string) {
  const response = await fetch("https://backend.app.com/auth?code=" + code);
  return response.ok;
}

function OTPInput() {
  const [isValidating, setIsValidating] = useState(false);
  const handleComplete = React.useCallback((code: string) => {
    validate(code).then().catch(console.error);
  }, []);

  return (
    <Passcode onComplete={handleComplete} isValidating={isValidating}>
      <Passcode.Input />
      <Passcode.Input />
      <Passcode.Input />
      <Passcode.Input />
    </Passcode>
  );
}
Enter fullscreen mode Exit fullscreen mode

Conslusions

In this article i have left out aspects of the behavior that are subjective.
For a full review of the code refer to the github repository.

Top comments (0)