OtpInput
A one-time code, a box per character: type it, paste it or let the phone fill it in from a text message.
- Type the code, or paste 907-214: every slot fills and the dash is left out.
- One Tab stop, landing on the next empty slot; arrows move between slots, Backspace deletes and steps back.
- Click a later empty slot: typing still starts at the first empty one.
Field marks
- Import
import { OtpInput } from "cennge-ui";- Size
- 3.5 KB gzip for this import, its dependencies aside
- Pattern
- A labelled
groupof one-character text inputs - Keyboard
- Arrow left Arrow right HomeEndBackspace Delete Enter
- State
- Controlled with
valueandonValueChange, or uncontrolled withdefaultValue - 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
- Source
- src/components/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.
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.
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.
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#
Also takes every attribute of <div>, except defaultValue, onChange, color, placeholder, dir, which mean something else here.