Siteplane UI

OTP Field

Enter a short verification code in separate or joined slots.

Markdown

Enter the 6-digit code we sent to your email.

"use client";

import {
  Field,
  FieldDescription,
  FieldLabel,
} from "@/siteplane/components/field";
import { OTPField, OTPFieldInput } from "@/siteplane/components/otp-field";

export default function Example() {
  return (
    <Field className="items-center">
      <FieldLabel>Verification code</FieldLabel>
      <OTPField length={6}>
        {Array.from({ length: 6 }, (_, index) => (
          <OTPFieldInput
            key={index}
            aria-label={index === 0 ? undefined : `Digit ${index + 1} of 6`}
          />
        ))}
      </OTPField>
      <FieldDescription>
        Enter the 6-digit code we sent to your email.
      </FieldDescription>
    </Field>
  );
}

Installation

pnpm dlx shadcn@4.18.0 add @siteplane/otp-field

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

pnpm dlx shadcn@4.18.0 add @siteplane/button @siteplane/form @siteplane/input

Field is installed with Input.

Usage

import { OTPField, OTPFieldInput } from "@/siteplane/components/otp-field";

<OTPField length={4} aria-label="Verification code">
  {Array.from({ length: 4 }, (_, index) => (
    <OTPFieldInput
      key={index}
      aria-label={index === 0 ? undefined : `Digit ${index + 1} of 4`}
    />
  ))}
</OTPField>;

Composition

OTPField
├── OTPFieldInput × length
└── OTPFieldSeparator (optional between slots)

Grouped slots

Separators do not count toward length. Render exactly the declared number of inputs.

"use client";

import {
  OTPField,
  OTPFieldInput,
  OTPFieldSeparator,
} from "@/siteplane/components/otp-field";

export default function Example() {
  return (
    <OTPField length={6} aria-label="Verification code">
      <OTPFieldInput />
      <OTPFieldInput aria-label="Digit 2 of 6" />
      <OTPFieldInput aria-label="Digit 3 of 6" />
      <OTPFieldSeparator />
      <OTPFieldInput aria-label="Digit 4 of 6" />
      <OTPFieldInput aria-label="Digit 5 of 6" />
      <OTPFieldInput aria-label="Digit 6 of 6" />
    </OTPField>
  );
}

Joined slots

Set layout="joined" to draw the slots as one field instead of separate boxes. The field has the surface and height of an Input, follows size and variant, and separates the characters with thin dividers. States follow the separate boxes: the current slot takes the focus ring inside the field while the field keeps its edge, no slot shows a caret, an invalid field shows the invalid edge, and a disabled field fades.

A joined field fills the width of its container, like an Input. Set a width on OTPField, or on the Field around it, to size it. OTPFieldSeparator still splits the slots into groups.

Enter the 6-digit code we sent to your email.

"use client";

import {
  Field,
  FieldDescription,
  FieldLabel,
} from "@/siteplane/components/field";
import { OTPField, OTPFieldInput } from "@/siteplane/components/otp-field";

export default function Example() {
  return (
    <Field className="w-full max-w-72">
      <FieldLabel>Verification code</FieldLabel>
      <OTPField layout="joined" length={6}>
        {Array.from({ length: 6 }, (_, index) => (
          <OTPFieldInput
            key={index}
            aria-label={index === 0 ? undefined : `Digit ${index + 1} of 6`}
          />
        ))}
      </OTPField>
      <FieldDescription>
        Enter the 6-digit code we sent to your email.
      </FieldDescription>
    </Field>
  );
}

Alphanumeric codes

Set validationType="alphanumeric" to accept letters and digits, for example for recovery codes.

Enter one of the backup codes you saved when you turned on two-factor authentication.

"use client";

import {
  Field,
  FieldDescription,
  FieldLabel,
} from "@/siteplane/components/field";
import { OTPField, OTPFieldInput } from "@/siteplane/components/otp-field";

export default function Example() {
  return (
    <Field className="w-full max-w-sm items-center">
      <FieldLabel>Recovery code</FieldLabel>
      <OTPField length={6} validationType="alphanumeric">
        {Array.from({ length: 6 }, (_, index) => (
          <OTPFieldInput
            key={index}
            aria-label={index === 0 ? undefined : `Character ${index + 1} of 6`}
          />
        ))}
      </OTPField>
      <FieldDescription className="text-center">
        Enter one of the backup codes you saved when you turned on two-factor
        authentication.
      </FieldDescription>
    </Field>
  );
}

Controlled value

Use value and onValueChange to own the code. onValueComplete fires once every slot is filled; verify the code in your application.

Enter the code from your authenticator app.

"use client";

import {
  Field,
  FieldDescription,
  FieldLabel,
} from "@/siteplane/components/field";
import { OTPField, OTPFieldInput } from "@/siteplane/components/otp-field";
import { useState } from "react";

export default function Example() {
  const [value, setValue] = useState("");

  return (
    <Field className="items-center">
      <FieldLabel>One-time password</FieldLabel>
      <OTPField length={6} value={value} onValueChange={setValue}>
        {Array.from({ length: 6 }, (_, index) => (
          <OTPFieldInput
            key={index}
            aria-label={index === 0 ? undefined : `Digit ${index + 1} of 6`}
          />
        ))}
      </OTPField>
      <FieldDescription>
        Enter the code from your authenticator app.
      </FieldDescription>
    </Field>
  );
}

Variants, masking and disabled

Masking only hides the displayed characters. Disabled and readOnly are separate states.

"use client";

import { Field, FieldLabel } from "@/siteplane/components/field";
import { OTPField, OTPFieldInput } from "@/siteplane/components/otp-field";

const fields: {
  defaultValue?: string;
  disabled?: boolean;
  label: string;
  mask?: boolean;
  variant?: "default" | "elevated" | "inset";
}[] = [
  { label: "Verification code, default", variant: "default" },
  {
    defaultValue: "1234",
    label: "PIN, elevated and masked",
    mask: true,
    variant: "elevated",
  },
  { label: "Verification code, inset", variant: "inset" },
  {
    defaultValue: "1234",
    label: "Verification code, disabled",
    disabled: true,
  },
];

export default function Example() {
  const slots = Array.from({ length: 4 }, (_, index) => (
    <OTPFieldInput
      key={index}
      aria-label={index === 0 ? undefined : `Digit ${index + 1} of 4`}
    />
  ));

  return (
    <div className="grid gap-4">
      {fields.map(({ defaultValue, disabled, label, mask, variant }) => (
        <Field disabled={disabled} key={label}>
          <FieldLabel>{label}</FieldLabel>
          <OTPField
            defaultValue={defaultValue}
            length={4}
            mask={mask}
            variant={variant}
          >
            {slots}
          </OTPField>
        </Field>
      ))}
    </div>
  );
}

Sizes

Omit size to inherit the provider's control size.

"use client";

import { Field, FieldLabel } from "@/siteplane/components/field";
import { OTPField, OTPFieldInput } from "@/siteplane/components/otp-field";

const sizes = [
  { size: "sm", label: "small" },
  { size: "md", label: "medium" },
  { size: "lg", label: "large" },
  { size: "xl", label: "extra large" },
] as const;

export default function Example() {
  const slots = Array.from({ length: 4 }, (_, index) => (
    <OTPFieldInput
      key={index}
      aria-label={index === 0 ? undefined : `Digit ${index + 1} of 4`}
    />
  ));

  return (
    <div className="grid gap-4">
      {sizes.map(({ size, label }) => (
        <Field key={size}>
          <FieldLabel>Verification code, {label}</FieldLabel>
          <OTPField length={4} size={size}>
            {slots}
          </OTPField>
        </Field>
      ))}
    </div>
  );
}

Form integration

Give the Field a name to submit the complete code. autoSubmit can submit the form once all slots are filled.

"use client";

import { Button } from "@/siteplane/components/button";
import { Field, FieldError, FieldLabel } from "@/siteplane/components/field";
import { Form } from "@/siteplane/components/form";
import { OTPField, OTPFieldInput } from "@/siteplane/components/otp-field";

export default function Example() {
  return (
    <Form
      className="flex flex-col items-start gap-4"
      onSubmit={(event) => event.preventDefault()}
    >
      <Field name="code">
        <FieldLabel>Verification code</FieldLabel>
        <OTPField length={6} required>
          {Array.from({ length: 6 }, (_, index) => (
            <OTPFieldInput
              key={index}
              aria-label={index === 0 ? undefined : `Digit ${index + 1} of 6`}
            />
          ))}
        </OTPField>
        <FieldError>Enter the 6-digit code.</FieldError>
      </Field>
      <Button type="submit">Verify</Button>
    </Form>
  );
}

API reference

Component properties
PropTypeDefaultDescription
OTPField.layoutseparate | joinedseparateOne box per slot, or one joined field with the surface of an Input and a divider between the slots.
OTPField.sizesm | md | lg | xlprovider / lgSlot dimensions; the height of a joined field.
OTPField.variantdefault | elevated | insetdefaultSurface of each slot, or of the joined field.
OTPField.aria-labelstringunsetCreates a hidden Label for the first input unless aria-labelledby is supplied.
OTPField.idstringgenerated when aria-label existsFirst input ID.
OTPFieldInput.spellCheckbooleanfalseSpelling checks disabled unless overridden.
lengthnumberrequiredNumber of input slots.
value / defaultValuestringuncontrolledComplete code.
onValueChange(value, eventDetails) => voidunsetCode-change callback.
onValueComplete(value, eventDetails) => voidunsetCalled on completion; does not authenticate.
onValueInvalid(value, eventDetails) => voidunsetReports characters rejected during normalization.
validationTypeOTP validation modenumericAllowed characters.
mask / autoSubmitbooleanfalseMask slots or submit on completion.

OTPFieldProps, OTPFieldInputProps and OTPFieldSeparatorProps describe these parts. OTPFieldPrimitive exposes Root, Input and the Base UI Separator. Styled Separator uses a short horizontal divider and accepts Separator props. A joined field draws the dividers between its slots itself. Other normalization, required, readOnly, disabled, autocomplete, name and form contracts follow Base UI OTP Field. Styled className values are strings.

Read the installed source.

Usage notes

Typing, pasting, deletion and slot focus are handled by the component. Give the field and slots meaningful names: the first slot takes the field's label and ignores its own aria-label, so label only the following slots. Never treat a client-side completion callback as verification; server validation and retry/rate limits belong to the application.