Monet
@etched/monet 0.0.1

OTP input

Ported

A one-time code, one box per character. Typing moves to the next box and Backspace goes back; a paste or the browser's autofill (autocomplete="one-time-code") fills every box. Digits only, unless validationType says otherwise. onValueComplete runs when the last box is filled. Native <div> attributes and ref pass through.

import { OTPInput } from "@etched/monet/otp-input";

export function Preview() {
  return <OTPInput aria-label="Verification code" groupSize={3} />;
}

Installation

import { OTPInput } from "@etched/monet/otp-input";

Examples

Verification code

Typing moves to the next box, Backspace goes back, and a paste fills every box. onValueComplete runs when the last box is filled.

We sent a 6-digit code to ana@etched.ai.

import { useState } from "react";
import { OTPInput } from "@etched/monet/otp-input";

export function VerificationCode() {
  const [code, setCode] = useState("");
  const [checked, setChecked] = useState("");

  return (
    <OTPInput
      label="Verification code"
      groupSize={3}
      value={code}
      onValueChange={(value) => {
        setCode(value);
        setChecked("");
      }}
      onValueComplete={setChecked}
      description={checked ? `Checking ${checked}…` : "We sent a 6-digit code to ana@etched.ai."}
    />
  );
}

Length, groups and characters

length sets the boxes and groupSize splits them. validationType="alphanumeric" takes letters too; normalizeValue can make them upper case.

Shown as dots.

import { OTPInput } from "@etched/monet/otp-input";

export function LengthGroupsAndCharacters() {
  return (
    <div className="grid">
      <OTPInput label="Backup code" length={8} groupSize={4} validationType="alphanumeric" normalizeValue={(value) => value.toUpperCase()} />
      <OTPInput label="Bench PIN" length={4} mask description="Shown as dots." />
    </div>
  );
}

Sizes

import { OTPInput } from "@etched/monet/otp-input";

export function Sizes() {
  return (
    <div className="grid">
      <OTPInput aria-label="Code (small)" size="sm" groupSize={3} />
      <OTPInput aria-label="Code" groupSize={3} />
    </div>
  );
}

States

That code has expired. Ask for a new one.
import { OTPInput } from "@etched/monet/otp-input";

export function States() {
  return (
    <div className="grid">
      <OTPInput label="Sign-in code" groupSize={3} defaultValue="482913" error="That code has expired. Ask for a new one." />
      <OTPInput label="Code sent to the bench" groupSize={3} defaultValue="105377" readOnly />
      <OTPInput label="Code (locked)" groupSize={3} disabled />
    </div>
  );
}

Accessibility

  • Typing fills a box and moves on; Backspace goes back. ArrowLeft/ArrowRight move one box; Home/End go to the start and end of the code.
  • Only one box is in the tab order, so Tab leaves the code in one step. A paste or one-time-code autofill fills every box.
  • A group named by label, or by aria-label when no label shows. The first box takes that name; the others are named "Character 2 of 6" and so on, or by characterLabel.
  • description and error describe the group, and an error sets aria-invalid on each box.
  • Give it a label, or an aria-label when no label shows.

API reference

PropTypeDefaultDescription
labelReactNode–Visible label. Without one, give the control an aria-label.
labelTooltipReactNode–More about the field, behind an info button after the label.
labelTooltipLabelstring"More information"Names the info button.
descriptionReactNode–Help text below the control. Hidden while an error shows.
errorFieldError–
lengthnumber6How many characters, one box each.
groupSizenumberall in one groupBoxes per group, with a separator between groups, such as 3 for "123–456".
size"sm" | "base""base"28 or 36 px boxes, as Input.
characterLabel(index: number, length: number) => string`Character ${index + 1} of ${length}`Names each box after the first, which takes the field's name.
classNamestring–Class on the outer element, for layout.