ReactHustle

How to Build a React OTP Input From Scratch Using DaisyUI

Jasser Mark Arioste

Jasser Mark Arioste

How to Build a React OTP Input From Scratch Using DaisyUI

Today, we're going to build a react OTP input from scratch using DaisyUI. There are many ready-made OTP input packages out there but there are definitely benefits to building your own components. For one, you have complete control over the code and any customizations in the behavior. It also allows you to hone your skills. 

Aside from that, I want to help beginner developers to think systematically when building components. One of the best traits of great developers is their problem-solving ability and the ability to make decisions when necessary.

By building components like these, you can improve your skills by leaps and bounds.

I use DaisyUI because it provides maximum flexibility with the styling. However, you can use any kind of styling library depending on your project. You can use pure TailwindCSS but it's a bit more verbose than just using DaisyUI.

Final Output #

This is the final output of what we will be building. 

OTP Input using DaisyUI

Requirements / Specs / Limitations #

Here's a list of specs for the OTP Input component. This is a guide, so you can add or remove depending on your situation.

Functional Requirements

  1. It should be possible to override the number of characters for the OTP code. The default is 6 characters.
  2. It should be possible to set the validation pattern for each input.
  3. The inputs should be separated for each character.
  4. It should allow copy-paste. We should handle this since the input is separated
  5. Pressing the "Backspace" key should delete the value of the current input. It should also transfer the focus to the previous input if applicable.
  6. Each input should have a limit of 1 character.
  7. Mobile: It should automatically submit the form once an OTP SMS message is received. We'll be testing this after deployment.

Non-Functional Requirements

  1. Responsive. It should look good on mobile and desktop.
  2. Our code should use Typescript for maintainability and readability.  

Cool. With that out of the way, let's start by setting up our project to use DaisyUI.

Project Setup #

We'll be using NextJS (with Typescript) as our React framework for this tutorial. So first, let's create a new project in NextJS.

npx create-next-app --ts
1

To set up DaisyUI, you can follow my previous tutorial: "How to set up DaisyUI theme in NextJS". If you are using a different react framework for your project, please check the daisyUI installation docs for a specific example.

Building the OTP Input Component #

Let's create the file in components/OtpInput.tsx. Let's also define the props for our component:

// components/OtpInput.tsx

/**
 * Let's borrow some props from HTML "input". More info below:
 *
 * [Pick Documentation](https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys)
 *
 * [How to extend HTML Elements](https://reacthustle.com/blog/how-to-extend-html-elements-in-react-typescript)
 */
type PartialInputProps = Pick<
  React.ComponentPropsWithoutRef<"input">,
  "className" | "style"
>;

type Props = {
  /**
   * full value of the otp input, up to {size} characters
   */
  value: string;
  onChange(value: string): void;
  /**
   * Number of characters/input for this component
   */
  size?: number;
  /**
   * Validation pattern for each input.
   * e.g: /[0-9]{1}/ for digits only or /[0-9a-zA-Z]{1}/ for alphanumeric
   */
  validationPattern?: RegExp;
} & PartialInputProps;
123456789101112131415161718192021222324252627282930

Next, let's define the component itself without any interactivity.

// components/OtpInput.tsx
...

const OtpInput = (props: Props) => {
  const {
    //Set the default size to 6 characters
    size = 6,
    //Default validation is digits
    validationPattern = /[0-9]{1}/,
    value,
    onChange,
    className,
    ...restProps
  } = props;

  // Create an array based on the size.
  const arr = new Array(size).fill("-");
  return (
    <div className="flex gap-2">
      {/* Map through the array and render input components */}
      {arr.map((_, index) => {
        return (
          <input
            key={index}
            {...restProps}
            /**
             * Add some styling to the input using daisyUI + tailwind.
             * Allows the user to override the className for a different styling
             */
            className={className || `input input-bordered px-0 text-center`}
            type="text"
            inputMode="numeric"
            autoComplete="one-time-code"
            pattern={validationPattern.source}
            maxLength={6}
            value={value.at(index) ?? ""}
          />
        );
      })}
    </div>
  );
};

export default OtpInput;
1234567891011121314151617181920212223242526272829303132333435363738394041424344

A few things to note from the above code.

In order to provide the best user experience for SMS OTP for mobile, we use some properties in our input elements:

  1. type="text" - It is better to use this instead of type="number" since that will produce a spinner at the left.
  2. inputMode="numeric" - transforms the mobile keypad into a numeric keypad.
  3. autoComplete="one-time-code" - Once the user receives an OTP code from SMS. This allows the browser to suggest an autocomplete for improved user experience.

Let's use our component on the index page and see what it looks like.

// pages/index.tsx
import type { NextPage } from "next";
import { useState } from "react";
import OtpInput from "../components/OtpInputPartial";

const Home: NextPage = () => {
  const [otp, setOtp] = useState("");
  return (
    <div className="container">
      <div className="h-screen grid place-content-center bg-base-100">
        <OtpInput
          value={otp}
          onChange={(val) => {
            setOtp(val);
          }}
        />
      </div>
    </div>
  );
};

export default Home;
12345678910111213141516171819202122

Our OTP Input component now looks like this. I'm using dark mode in my system that's why it uses a dark theme. Looks good already!

How OTP input looks after the first step:

Adding Interactivity or Functionality #

Step 1: Allowing the user to type the code.

Let's allow the user to input his code by adding an onChange handler to our inputs.

// components/OtpInput.tsx

const OtpInput = (props: Props) => {
  ...

  const handleInputChange = (
    e: React.ChangeEvent<HTMLInputElement>,
    index: number
  ) => {
    const elem = e.target;
    const val = e.target.value;
    // check if the value is valid
    if (!validationPattern.test(val) && val !== "") return;
    
    // change the value of the upper state using onChange
    const valueArr = value.split("");
    valueArr[index] = val;
    const newVal = valueArr.join("").slice(0, 6);
    onChange(newVal);

    //focus the next element if there's a value
    if (val) {
      const next = elem.nextElementSibling as HTMLInputElement | null;
      next?.focus();
    }
  };

  return (
    <div className="flex gap-2">
      {arr.map((_, index) => {
        return (
          <input
            ...
            onChange={(e) => handleInputChange(e, index)}
          />
        );
      })}
    </div>
  );
};

export default OtpInput;
123456789101112131415161718192021222324252627282930313233343536373839404142

Step 2: Improving accessibility

Currently, when the user hits the "Backspace", ArrowLeft" or "ArrowRight" keys, it doesn't do anything. Let's improve the user experience by adding an onKeyUp event handler:

...
const OtpInput = (props: Props) => {
  ... 
  const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
    const current = e.currentTarget;
    if (e.key === "ArrowLeft" || e.key === "Backspace") {
      const prev = current.previousElementSibling as HTMLInputElement | null;
      prev?.focus();
      prev?.setSelectionRange(0, 1);
      return;
    }

    if (e.key === "ArrowRight") {
      const prev = current.nextSibling as HTMLInputElement | null;
      prev?.focus();
      prev?.setSelectionRange(0, 1);
      return;
    }
  };
  return (
    <div className="flex gap-2">
      {/* Map through the array and render input components */}
      {arr.map((_, index) => {
        return (
          <input
            ...
            onKeyUp={handleKeyUp}
          />
        );
      })}
    </div>
}
1234567891011121314151617181920212223242526272829303132

Here's a simple demo after the first two steps:

Step 3: Adding Copy-Paste Functionality

Since our inputs are separated, we have to modify the copy-paste functionality so that the copied text propagates to the other inputs.

...
const OtpInput = (props: Props) => {
  ... 
  const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
    e.preventDefault();
    const val = e.clipboardData.getData("text").substring(0, size);
    onChange(val);
  };

  return (
    <div className="flex gap-2">
      {/* Map through the array and render input components */}
      {arr.map((_, index) => {
        return (
          <input
            ...
            onPaste={handlePaste}
          />
        );
      })}
    </div>
}
12345678910111213141516171819202122

Now copying some code and pasting it should

OTP Input Copy-Paste Demo

Allowing automatic form submission using WebOTP API #

We've already completed the functionality of the OTPInput component. Now, let us take it a step further by allowing mobile users to automatically submit the form when they receive an OTP SMS. Our mini-project won't be complete without this!

Step 4: Creating the OtpForm component

Let's create the file components/OtpForm.tsx 

import React, { useState } from "react";
import OtpInput from "components/OtpInputPartial";

const OtpForm = () => {
  const [otp, setOtp] = useState("");

  return (
    <form
      className="card shadow-md bg-base-200"
      onSubmit={async (e) => {
        e.preventDefault();
      }}
    >
      <div className="card-body items-stretch text-center">
        <div className="my-2">
          <h2 className="text-xl"> One-Time Password</h2>
          <p className="text-sm text-base-content/80"> Input the code</p>
        </div>
        <OtpInput
          value={otp}
          onChange={(val) => {
            setOtp(val);
          }}
        />
        <button className="btn btn-primary mt-2" type="submit">
          Verify OTP
        </button>
      </div>
    </form>
  );
};

export default OtpForm;
123456789101112131415161718192021222324252627282930313233

Now, let's use it on our index page (pages/index.tsx).

import type { NextPage } from "next";
import OtpForm from "components/OtpForm";

const Home: NextPage = () => {
  return (
    <div className="container">
      <div className="h-screen grid place-content-center bg-base-100">
        <OtpForm />
      </div>
    </div>
  );
};

export default Home;
1234567891011121314

Looking good so far!

Adding a form around the OTP input component

Step 5: Using the WebOTP API

Now that we have a form, let's add an auto-submit functionality using the WebOTP API. First, let's add the following to our OtpForm:

// components/OtpForm.tsx

const OtpForm = () => {
  const [otp, setOtp] = useState("");
  //Add a reference to our form so that we can use it later
  const ref = useRef<HTMLFormElement>(null!);

  useEffect(() => {
    // Feature detection
    if ("OTPCredential" in window) {
      const form = ref.current;
      //Allows us to cancel the web API if the form is submitted manually
      const ac = new AbortController();
      const handler = () => {
        ac.abort();
      };
      form.addEventListener("submit", handler);

      //Let the browser listen for any sms message containing an OTP code.
      navigator.credentials
        .get({
          // @ts-ignore
          otp: { transport: ["sms"] },
          signal: ac.signal,
        })
        .then((otp: any) => {
          //set the state and submit the form automatically
          setOtp(otp.code);
          form.submit();
        })
        .catch((err) => {
          console.log(err);
        });

      //Cleanup useEffect
      return () => {
        form.removeEventListener("submit", handler);
      };
    }
  }, []);

  //temporary handle submit function  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    console.log("Submitting code to API");
  };

  ...
  return (
    <form
      className="card shadow-md bg-base-200"
      onSubmit={handleSubmit}
      ref={ref}
    >
    ...
    </form>
  )
}
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758

There's a lot going on here so let's break it down step-by-step:

Feature Detection

useEffect(() => {
    // Feature detection
    if ("OTPCredential" in window) {
        ...
    }
}, [])
123456

This is pretty self-explanatory. It checks if the browser can parse OTP SMS messages by checking the OTPCredential class.

Aborting the message on manual Submit


//Allows us to cancel the web API if the form is submitted manually
const ac = new AbortController();
const handler = () => {
  ac.abort();
};
form.addEventListener("submit", handler);
1234567

If the user wishes to manually input the code and submit, this allows us to cancel the .get() function if the form is submitted manually. More info on AbortController.

Triggering the Browser Permissions


navigator.credentials
  .get({
    // @ts-ignore
    otp: { transport: ["sms"] },
    signal: ac.signal,
  })
1234567

This triggers the browser permissions to allow it to read the SMS message once it arrives. More info is available here.

For example:

OTP Permission Flow

Processing the Message

Once the browser gets the OTP code, it returns an OTPCredential object. It has a code property which we'll use to set our otp state in react and submit the form.

        ...
        .then((otp: any) => {
          //set the state and submit the form automatically
          setOtp(otp.code);
          form.submit();
        })
        .catch((err) => {
          console.log(err);
        });
        ...
12345678910

Wiring it all together

Now we just need to add a submit handler for our form. You can do anything here depending on your application. Once you verify the code, you can show a message or redirect the user, create a session, etc. It depends on your requirements.

  ...
  //temporary handle submit function  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    console.log("Submitting code to API");
  };

  ...
  return (
    <form
      className="card shadow-md bg-base-200"
      onSubmit={handleSubmit}
      ref={ref}
    >
    ...
    </form>
  )
1234567891011121314151617

In my case, I implemented an API route to verify the otp code and called it from the handleSubmit function. Below is an example

Implementing a mock verify-otp API

This is a very crude API implementation and it's for example purposes only.

// pages/api/verify-otp.ts

import type { NextApiRequest, NextApiResponse } from "next";

export default function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method === "POST") {
    const code = req.body.code;
    console.log(req.body, req.body.code);
    if (code === "123456") {
      res.status(200).json({ success: true });
    } else {
      res.status(401).json({ success: false, message: "invalid code" });
    }
  } else {
    res.status(405).send("Method not allowed");
  }
  res.end();
}
123456789101112131415161718

Using the verify-otp API onSubmit

// components/OtpForm.tsx
...
import classNames from "classnames";

const OtpForm = () => {
  ...
  const [submitting, setSubmitting] = useState(false);
  const [message, setMessage] = useState("");
  ...
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setSubmitting(true);
    try {
      const response = await fetch(`/api/verify-otp`, {
        headers: {
          Accept: "application/json",
          "Content-Type": "application/json",
        },
        method: "POST",
        body: JSON.stringify({ code: otp }),
      });
      type Result = {
        success: boolean;
        message?: string;
      };
      const result: Result = await response.json();
      setSubmitting(false);
      if (result.success) {
        setMessage("You are now verified.");
      } else {
        setMessage(result.message ?? "Invalid code");
        setOtp("");
      }
    } catch (e) {
      if (e instanceof Error) {
        alert(e.message);
      }
    } finally {
      setSubmitting(false);
    }
  };

  if (message) {
    return (
      <div className="flex flex-col items-stretch gap-2">
        <p className="text-xl">{message}</p>
        <button
          onClick={() => {
            setMessage("");
            setOtp("");
          }}
          className="btn btn-primary"
        >
          Reset Form
        </button>
      </div>
    );
  }
    return (
    <form
       ...
    >
      <div className="card-body items-stretch text-center">
        ...
        <button
          className={classNames({
            "btn btn-primary": true,
            loading: submitting,
          })}
          type="submit"
        >
          Verify OTP
        </button>
      </div>
    </form>
  );
}
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677

The above code is pretty straightforward. In the handleSubmit function, we just call the API and set the appropriate message depending on the result. But it totally depends on you what to do here. You can also redirect the user to another page if the code is valid.

Now code is complete, we can test if our React OTP Input works. You can check the full code here: https://github.com/jmarioste/react-otp-input

Manual Testing #

To test the WebOTP functionality, we have to deploy it since the WebOTP is only available through HTTPS. I deployed the app in Vercel: https://react-otp-input-zeta.vercel.app/

Next, you can go to the app and you can send a test OTP code to your phone number. Send the message below to your phone number:

Your OTP is: 123456.

@react-otp-input-zeta.vercel.app #123456
12

Once you receive the message, it should automatically submit the form and show you this screen:

User is verified

If you input an incorrect code, it shows you this screen:

Conclusion #

We learned how to build a React OTP Input component as well as the best practices for web otp and accessibility. We also learned how to use the WebOTP API to automatically submit the OTP code once the browser receives an SMS message. 

If you like this tutorial, please leave a like or share this article. For future tutorials like this, please subscribe to our newsletter or follow me on Twitter.

Resources / Further Reading #

SMS OTP form best practices - https://web.dev/sms-otp-form/

Verify phone numbers on the web with the WebOTP API - https://web.dev/web-otp/

Share this post!

Related Posts