Skip to content

OTP field

A one-time code entered one character per slot. Supports paste and one-time-code autofill. For longer values, such as API keys, use Input.

Code
demo.tsx
import {Field, OTPField} from '@earendil-works/elanor-react';

const LENGTH = 6;

export default function OTPFieldDemo() {
  return (
    <Field.Root name="code">
      <Field.Label>Verification code</Field.Label>
      <OTPField.Root length={LENGTH}>
        {Array.from({length: LENGTH}, (_, index) => (
          <OTPField.Input
            key={index}
            aria-label={index === 0 ? undefined : `Character ${index + 1} of ${LENGTH}`}
          />
        ))}
      </OTPField.Root>
    </Field.Root>
  );
}

Usage

import {Field, OTPField} from '@earendil-works/elanor-react';

<Field.Root name="code">
  <Field.Label>Verification code</Field.Label>
  <OTPField.Root length={6}>
    <OTPField.Input />
    <OTPField.Input aria-label="Character 2 of 6" />
    {/* … one Input per character */}
  </OTPField.Root>
</Field.Root>;

Field.Label labels the first slot; give the others an aria-label. The field submits one value under the Field.Root’s name.

Validation

validationType defaults to numeric and sets a native pattern, so an incomplete code fails with patternMismatch. Give it a Field.Error, otherwise the browser’s message shows.

Code
validation.tsx
import {Button, Field, Form, OTPField, Stack} from '@earendil-works/elanor-react';

const LENGTH = 6;

export default function OTPFieldValidation() {
  return (
    <Form>
      <Stack gap="medium">
        <Field.Root name="code">
          <Field.Label>Verification code</Field.Label>
          <OTPField.Root length={LENGTH} required>
            {Array.from({length: LENGTH}, (_, index) => (
              <OTPField.Input
                key={index}
                aria-label={index === 0 ? undefined : `Character ${index + 1} of ${LENGTH}`}
              />
            ))}
          </OTPField.Root>
          <Field.Error match="valueMissing">Enter the code from the email.</Field.Error>
          <Field.Error match="patternMismatch">Enter all 6 digits.</Field.Error>
        </Field.Root>
        <Button type="submit">Verify</Button>
      </Stack>
    </Form>
  );
}

Grouped

Code
grouped.tsx
import {Field, OTPField} from '@earendil-works/elanor-react';

const LENGTH = 6;

function slot(index: number) {
  return (
    <OTPField.Input
      key={index}
      aria-label={index === 0 ? undefined : `Character ${index + 1} of ${LENGTH}`}
    />
  );
}

export default function OTPFieldGrouped() {
  return (
    <Field.Root name="code">
      <Field.Label>Verification code</Field.Label>
      <OTPField.Root length={LENGTH}>
        {[0, 1, 2].map(slot)}
        <OTPField.Separator />
        {[3, 4, 5].map(slot)}
      </OTPField.Root>
    </Field.Root>
  );
}

Alphanumeric

Use validationType="alphanumeric" for recovery and invite codes. normalizeValue runs on every edit, so it must return the same value when run twice; define it outside the component.

Code
alphanumeric.tsx
import {Field, OTPField} from '@earendil-works/elanor-react';

const LENGTH = 8;

function uppercase(value: string) {
  return value.toUpperCase();
}

export default function OTPFieldAlphanumeric() {
  return (
    <Field.Root name="recoveryCode">
      <Field.Label>Recovery code</Field.Label>
      <OTPField.Root length={LENGTH} validationType="alphanumeric" normalizeValue={uppercase}>
        {Array.from({length: LENGTH}, (_, index) => (
          <OTPField.Input
            key={index}
            aria-label={index === 0 ? undefined : `Character ${index + 1} of ${LENGTH}`}
          />
        ))}
      </OTPField.Root>
    </Field.Root>
  );
}

Full width

Slots are square. Override their size with a class to fill the container.

Code
full-width.tsx
import {Field, OTPField} from '@earendil-works/elanor-react';

import styles from './otp-field.module.css';

const LENGTH = 6;

export default function OTPFieldFullWidth() {
  return (
    <Field.Root name="code">
      <Field.Label>Verification code</Field.Label>
      <OTPField.Root length={LENGTH}>
        {Array.from({length: LENGTH}, (_, index) => (
          <OTPField.Input
            key={index}
            className={styles.stretch}
            aria-label={index === 0 ? undefined : `Character ${index + 1} of ${LENGTH}`}
          />
        ))}
      </OTPField.Root>
    </Field.Root>
  );
}
otp-field.module.css
.stretch {
  flex: 1 1 0;
  inline-size: auto;
}

API

OTPField.Root

Groups the slots and owns the value. Render one Input per character of length.

Accepts Base UI OTPField.Root props.

OTPField.Input

One character slot. Label every slot after the first, for example "Character 2 of 6".

Accepts Base UI OTPField.Input props.

OTPField.Separator

Splits the slots into groups, such as 123–456.

Accepts Base UI Separator props.