Skip to content
cennge-ui
GitHubv0.3.1, changelog

OtpInput

A one-time code, a box per character: type it, paste it or let the phone fill it in from a text message.

Verification code
6 digits, sent to dev@example.com

Code from yesterday
This code has expired; ask for a new one
  1. Type the code, or paste 907-214: every slot fills and the dash is left out.
  2. One Tab stop, landing on the next empty slot; arrows move between slots, Backspace deletes and steps back.
  3. Click a later empty slot: typing still starts at the first empty one.
Live: the component itself, not a picture of it.

Field marks

Import
import { OtpInput } from "cennge-ui";
Size
3.5 KB gzip for this import, its dependencies aside
Pattern
A labelled group of one-character text inputs
Keyboard
Arrow left Arrow right HomeEndBackspace Delete Enter
State
Controlled with value and onValueChange, or uncontrolled with defaultValue
SSR
Rendered on the server in the Next.js check; hydrates without a warning
Autofill
autocomplete="one-time-code": a code from a text message fills every slot
Characters
Digits by default; others are ignored, pasted ones too
Tests
Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: otp-input

When to use#

Use an OtpInput for a short code someone copies from elsewhere: a sign-in code from an authenticator app, an email or a text message. The boxes show at a glance how long the code is and how much is left, and the field takes the code whichever way it arrives: typed one character at a time, pasted whole, or filled in by the phone's keyboard.

onComplete fires once every slot is filled, so the check can start without a button.

Similar#

  • PasswordInput is for a secret the person chose and remembers.
  • NumberInput is for a quantity; a code is not a number, and its leading zeros count.
  • Input fits codes of no fixed length, such as a coupon.

Check the code when it is complete#

onComplete gets the code as soon as the last slot is filled. Here the field is read-only while the code is checked, so focus stays in it, and a wrong one (try 000000) shows the error until the code is edited.

Confirm your email
Enter the code we sent to dev@example.com
verify.tsx
import { useState } from "react";
import { Button, OtpInput, Spinner } from "cennge-ui";

// Stands in for the request that checks the code on your server.
function check(code: string) {
  return new Promise<boolean>((resolve) => setTimeout(() => resolve(code !== "000000"), 1000));
}

export default function ConfirmEmail() {
  const [code, setCode] = useState("");
  const [status, setStatus] = useState<"idle" | "checking" | "wrong" | "confirmed">("idle");
  const [isRestarted, setRestarted] = useState(false);

  if (status === "confirmed") {
    return (
      <div className="flex items-center gap-3 text-sm">
        <span className="text-success-text">dev@example.com is confirmed</span>
        <Button
          size="sm"
          variant="flat"
          onClick={() => {
            setCode("");
            setStatus("idle");
            setRestarted(true);
          }}
        >
          Start over
        </Button>
      </div>
    );
  }

  return (
    <div className="flex flex-col gap-4">
      <OtpInput
        autoFocus={isRestarted}
        label="Confirm your email"
        description="Enter the code we sent to dev@example.com"
        value={code}
        onValueChange={(next) => {
          setCode(next);
          setStatus("idle");
        }}
        onComplete={async (complete) => {
          setStatus("checking");
          setStatus((await check(complete)) ? "confirmed" : "wrong");
        }}
        isReadOnly={status === "checking"}
        isInvalid={status === "wrong"}
        errorMessage="That code is not right"
      />
      {status === "checking" ? (
        <p className="flex items-center gap-2 text-sm text-muted">
          <Spinner size="sm" /> Checking the code
        </p>
      ) : null}
    </div>
  );
}

Letters and hidden characters#

allowedCharacters="alphanumeric" takes letters and digits, alpha only letters. isMasked hides each character as a password field does; length sets the number of slots and groupSize draws a separator between groups.

Recovery code
Letters and digits, from the list you saved
recovery.tsx
import { OtpInput } from "cennge-ui";

export default function RecoveryCode() {
  return (
    <OtpInput
      label="Recovery code"
      description="Letters and digits, from the list you saved"
      length={8}
      groupSize={4}
      allowedCharacters="alphanumeric"
      isMasked
      size="sm"
    />
  );
}

Submit when complete#

With a name, the code goes with the form in a hidden input. autoSubmit sends the form as soon as the last character is in; without it, Enter in any slot sends it.

Authenticator code
auto-submit.tsx
import { useState } from "react";
import { OtpInput } from "cennge-ui";

export default function TwoFactorForm() {
  const [submitted, setSubmitted] = useState("");

  return (
    <form
      className="flex flex-col gap-3"
      onSubmit={(event) => {
        event.preventDefault();
        setSubmitted(JSON.stringify(Object.fromEntries(new FormData(event.currentTarget))));
      }}
    >
      <OtpInput label="Authenticator code" name="code" groupSize={3} autoSubmit />
      {submitted ? <p className="font-mono text-[13px] text-muted">submitted: {submitted}</p> : null}
    </form>
  );
}

Accessibility#

The slots sit in a group labelled by the label and described by the description or the error. Each slot is named "Character 1 of 6" and so on; translate that with formatSlotLabel. While isInvalid, every slot has aria-invalid. The separators are hidden from screen readers.

The label is not a <label>, since it names a group, but a click on it still puts the caret where typing continues. autoFocus does the same when the field mounts.

Styling#

Each slot is the Input's box made square, with the same variant, color, size and radius. classNames reaches base, label, group, slot, separator, description and errorMessage.

Props#

PropTypeDefault
label
React.ReactNode
–
description
React.ReactNode
–
errorMessage
React.ReactNodeShown instead of description while isInvalid.
–
length
numberNumber of characters.
6
value
string
–
defaultValue
string
–
onValueChange
(value: string) => void
–
onComplete
(code: string) => voidCalled with the code once every slot is filled.
–
autoSubmit
booleanSubmit the surrounding form as soon as the code is complete.
false
allowedCharacters
"numeric""alphanumeric""alpha"Characters the code may contain; others are ignored, also when pasted.
"numeric"
isMasked
booleanHide the characters like a password.
false
groupSize
numberDraw a separator after every groupSize slots, e.g. 3 for 123–456.
–
placeholder
stringOne character per slot, shown while the field is empty, e.g. "000000".
–
formatSlotLabel
(position: number, length: number) => stringAccessible name of each slot.
(position, length) => `Character ${position} of ${length}`
name
stringSubmitted with forms under this name.
–
form
stringId of a form elsewhere on the page to submit with.
–
autoFocus
boolean
–
dir
"ltr""rtl"Writing direction; arrows follow it.
–
isInvalid
boolean
–
isDisabled
boolean
–
isReadOnly
boolean
–
variant
"bordered""flat""faded""underlined"
"flat"
color
"default""primary""secondary""success""warning""danger""info"Focus colour.
"primary"
size
"sm""md""lg"
"md"
radius
"none""sm""md""lg""full"
"md"
classNames
Partial<Record<OtpInputSlots, string>>
–

Also takes every attribute of <div>, except defaultValue, onChange, color, placeholder, dir, which mean something else here.