Skip to content
Lenso UI

InputOTP

A one-time password input component for verification codes and secure authentication

Usage

import { InputOTP } from '@lenso/ui';
"use client";// Adapted from HeroUI v3.2.6 (e385ac202b2cdb94b1bf6fa76d32c31c8259cc5e), Apache-2.0.import { InputOTP, Label, Link, TextField } from "@lenso/ui";import * as stylex from "@stylexjs/stylex";import { Slots, styles } from "./parts";export function Basic() {  return (    <TextField name="code" xstyle={styles.field}>      <div {...stylex.props(styles.heading)}>        <Label>Verify account</Label>        <p {...stylex.props(styles.muted)}>We&apos;ve sent a code to a****@gmail.com</p>      </div>      <InputOTP length={6} name="code">        <Slots />      </InputOTP>      <div {...stylex.props(styles.resend)}>        <p {...stylex.props(styles.muted)}>Didn&apos;t receive a code?</p>        <Link xstyle={styles.link} href="#">          Resend        </Link>      </div>    </TextField>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Anatomy

import { InputOTP } from '@lenso/ui';
export default () => (  <InputOTP maxLength={6}>    <InputOTP.Group>      <InputOTP.Slot index={0} />      <InputOTP.Slot index={1} />      {/* ...rest of the slots */}    </InputOTP.Group>    <InputOTP.Separator />    <InputOTP.Group>      <InputOTP.Slot index={3} />      {/* ...rest of the slots */}    </InputOTP.Group>  </InputOTP>)

InputOTP is built on top of input-otp by @guilherme_rodz, providing a flexible and accessible foundation for OTP input components.

Examples

Variants

The InputOTP component supports two visual variants:

  • primary (default) - Standard styling with shadow, suitable for most use cases
  • secondary - Lower emphasis variant without shadow, suitable for use in Surface components
"use client";// Adapted from HeroUI v3.2.6 (e385ac202b2cdb94b1bf6fa76d32c31c8259cc5e), Apache-2.0.import { InputOTP, Label, TextField } from "@lenso/ui";import * as stylex from "@stylexjs/stylex";import { Slots, styles } from "./parts";export function Variants() {  return (    <div {...stylex.props(styles.variants)}>      {(["primary", "secondary"] as const).map((variant) => (        <TextField key={variant} name={`${variant}-code`} xstyle={styles.field}>          <Label>{variant === "primary" ? "Primary variant" : "Secondary variant"}</Label>          <InputOTP length={6} name={`${variant}-code`} variant={variant}>            <Slots />          </InputOTP>        </TextField>      ))}    </div>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

In Surface

When used inside a Surface component, use variant="secondary" to apply the lower emphasis variant suitable for surface backgrounds.

"use client";// Adapted from HeroUI v3.2.6 (e385ac202b2cdb94b1bf6fa76d32c31c8259cc5e), Apache-2.0.import { InputOTP, Label, Link, Surface, TextField } from "@lenso/ui";import * as stylex from "@stylexjs/stylex";import { Slots, styles } from "./parts";export function OnSurface() {  return (    <Surface xstyle={styles.surface}>      <TextField name="code">        <div {...stylex.props(styles.heading)}>          <Label>Verify account</Label>          <p {...stylex.props(styles.muted)}>We&apos;ve sent a code to a****@gmail.com</p>        </div>        <InputOTP length={6} name="code" variant="secondary">          <Slots />        </InputOTP>        <div {...stylex.props(styles.resend)}>          <p {...stylex.props(styles.muted)}>Didn&apos;t receive a code?</p>          <Link xstyle={styles.link} href="#">            Resend          </Link>        </div>      </TextField>    </Surface>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Disabled State

"use client";// Adapted from HeroUI v3.2.6 (e385ac202b2cdb94b1bf6fa76d32c31c8259cc5e), Apache-2.0.import { Description, InputOTP, Label, TextField } from "@lenso/ui";import { Slots, styles } from "./parts";export function Disabled() {  return (    <TextField disabled name="code" xstyle={styles.field}>      <Label>Verify account</Label>      <Description>Code verification is currently disabled</Description>      <InputOTP disabled length={6} name="code">        <Slots />      </InputOTP>    </TextField>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Four Digits

"use client";// Adapted from HeroUI v3.2.6 (e385ac202b2cdb94b1bf6fa76d32c31c8259cc5e), Apache-2.0.import { InputOTP, Label, TextField } from "@lenso/ui";import { styles } from "./parts";export function FourDigits() {  return (    <TextField name="pin" xstyle={styles.field}>      <Label>Enter PIN</Label>      <InputOTP length={4} name="pin">        <InputOTP.Group>          <InputOTP.Slot aria-label="Digit 1" />          <InputOTP.Slot aria-label="Digit 2" />          <InputOTP.Slot aria-label="Digit 3" />          <InputOTP.Slot aria-label="Digit 4" />        </InputOTP.Group>      </InputOTP>    </TextField>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Controlled

Control the value to synchronize with state, clear the input, or implement custom validation.

"use client";// Adapted from HeroUI v3.2.6 (e385ac202b2cdb94b1bf6fa76d32c31c8259cc5e), Apache-2.0.import { Description, InputOTP, Label, TextField } from "@lenso/ui";import { useState } from "react";import * as stylex from "@stylexjs/stylex";import { Slots, styles } from "./parts";export function Controlled() {  const [value, setValue] = useState("");  return (    <TextField name="code" xstyle={styles.field}>      <Label>Verify account</Label>      <InputOTP length={6} name="code" value={value} onValueChange={setValue}>        <Slots />      </InputOTP>      <Description>        {value.length > 0 ? (          <>            Value: {value} ({value.length}/6) •{" "}            <button type="button" {...stylex.props(styles.clear)} onClick={() => setValue("")}>              Clear            </button>          </>        ) : (          "Enter a 6-digit code"        )}      </Description>    </TextField>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

On Complete

Use the onComplete callback to trigger actions when all slots are filled.

"use client";// Adapted from HeroUI v3.2.6 (e385ac202b2cdb94b1bf6fa76d32c31c8259cc5e), Apache-2.0.import { Button, Form, InputOTP, Label, Spinner, TextField } from "@lenso/ui";import { useState, type FormEvent } from "react";import { Slots, styles } from "./parts";export function OnComplete() {  const [value, setValue] = useState("");  const [isComplete, setIsComplete] = useState(false);  const [isSubmitting, setIsSubmitting] = useState(false);  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {    event.preventDefault();    if (!isComplete || isSubmitting) return;    setIsSubmitting(true);    setTimeout(() => {      setIsSubmitting(false);      setValue("");      setIsComplete(false);    }, 2000);  };  return (    <Form xstyle={styles.field} onSubmit={handleSubmit}>      <TextField name="code">        <Label>Verify account</Label>        <InputOTP          length={6}          name="code"          value={value}          onValueComplete={(code) => {            setIsComplete(true);            console.log("Code complete:", code);          }}          onValueChange={(next) => {            setValue(next);            setIsComplete(false);          }}        >          <Slots />        </InputOTP>      </TextField>      <Button        xstyle={styles.submit}        disabled={!isComplete}        isLoading={isSubmitting}        type="submit"        variant="primary"      >        {isSubmitting ? (          <>            <Spinner color="current" size="sm" />            Verifying...          </>        ) : (          "Verify Code"        )}      </Button>    </Form>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Form Example

A complete two-factor authentication form with validation and submission.

"use client";// Adapted from HeroUI v3.2.6 (e385ac202b2cdb94b1bf6fa76d32c31c8259cc5e), Apache-2.0.import {  Button,  Description,  FieldError,  Form,  InputOTP,  Label,  Link,  Spinner,  TextField,} from "@lenso/ui";import { useState, type FormEvent } from "react";import * as stylex from "@stylexjs/stylex";import { Slots, styles } from "./parts";export function FormExample() {  const [value, setValue] = useState("");  const [error, setError] = useState("");  const [isSubmitting, setIsSubmitting] = useState(false);  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {    event.preventDefault();    if (isSubmitting) return;    setError("");    if (value.length !== 6) {      setError("Please enter all 6 digits");      return;    }    setIsSubmitting(true);    setTimeout(() => {      if (value === "123456") {        console.log("Code verified successfully!");        setValue("");      } else setError("Invalid code. Please try again.");      setIsSubmitting(false);    }, 1500);  };  return (    <Form xstyle={styles.form} onSubmit={handleSubmit}>      <TextField name="code" invalid={!!error}>        <Label>Two-factor authentication</Label>        <Description>Enter the 6-digit code from your authenticator app</Description>        <InputOTP          length={6}          name="code"          value={value}          onValueChange={(next) => {            setValue(next);            setError("");          }}        >          <Slots />        </InputOTP>        {error && <FieldError match>{error}</FieldError>}      </TextField>      <Button        xstyle={styles.full}        disabled={value.length !== 6}        isLoading={isSubmitting}        type="submit"        variant="primary"      >        {isSubmitting ? (          <>            <Spinner color="current" size="sm" />            Verifying...          </>        ) : (          "Verify"        )}      </Button>      <div {...stylex.props(styles.help)}>        <p {...stylex.props(styles.muted)}>Having trouble?</p>        <Link xstyle={styles.link} href="#">          Use backup code        </Link>      </div>    </Form>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

With Pattern

Use the pattern prop to restrict input to specific characters. HeroUI exports common patterns like REGEXP_ONLY_CHARS and REGEXP_ONLY_DIGITS.

"use client";// Adapted from HeroUI v3.2.6 (e385ac202b2cdb94b1bf6fa76d32c31c8259cc5e), Apache-2.0.import { Description, InputOTP, Label, TextField } from "@lenso/ui";import { Slots, styles } from "./parts";export function WithPattern() {  return (    <TextField name="code" xstyle={styles.field}>      <Label>Enter code (letters only)</Label>      <Description>Only alphabetic characters are allowed</Description>      <InputOTP length={6} name="code" validationType="alpha">        <Slots />      </InputOTP>    </TextField>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

With Validation

Use isInvalid together with validation messages to surface errors.

"use client";// Adapted from HeroUI v3.2.6 (e385ac202b2cdb94b1bf6fa76d32c31c8259cc5e), Apache-2.0.import { Button, Description, FieldError, Form, InputOTP, Label, TextField } from "@lenso/ui";import { useState, type FormEvent } from "react";import { Slots, styles } from "./parts";export function WithValidation() {  const [value, setValue] = useState("");  const [isInvalid, setIsInvalid] = useState(false);  const onSubmit = (event: FormEvent<HTMLFormElement>) => {    event.preventDefault();    const code = new FormData(event.currentTarget).get("code");    if (code !== "123456") {      setIsInvalid(true);      return;    }    setIsInvalid(false);    setValue("");    alert("Code verified successfully!");  };  return (    <Form xstyle={styles.field} onSubmit={onSubmit}>      <TextField name="code" invalid={isInvalid}>        <Label>Verify account</Label>        <Description>Hint: The code is 123456</Description>        <InputOTP          length={6}          name="code"          value={value}          onValueChange={(next) => {            setValue(next);            setIsInvalid(false);          }}        >          <Slots />        </InputOTP>        {isInvalid && <FieldError match>Invalid code. Please try again.</FieldError>}      </TextField>      <Button disabled={value.length !== 6} type="submit">        Submit      </Button>    </Form>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Customization

Tailwind CSS

"use client";// Adapted from HeroUI v3.2.6 (e385ac202b2cdb94b1bf6fa76d32c31c8259cc5e), Apache-2.0.import { InputOTP, Label, Link, TextField } from "@lenso/ui";import { Slots, styles } from "./parts";export function CustomStyles() {  return (    <TextField name="code" xstyle={[styles.field, styles.customWidth]}>      <Label>Verify account</Label>      <InputOTP length={6} name="code">        <Slots custom />      </InputOTP>      <Link xstyle={styles.resendLink} href="#">        Resend code      </Link>    </TextField>  );}

Local adaptation source above. Derived from HeroUI v3.2.6 source.

Global CSS

To customize the InputOTP component classes, you can use the @layer components directive. Learn more.

@layer components {  .input-otp {    @apply gap-3;  }
  .input-otp__slot {    @apply size-12 rounded-xl border-2 font-bold;  }
  .input-otp__slot[data-active="true"] {    @apply border-accent-500 ring-2 ring-accent-200;  }
  .input-otp__separator {    @apply w-2 h-1 bg-border-strong rounded-full;  }}

Styling Reference

HeroUI follows the BEM methodology to ensure component variants and states are reusable and easy to customize.

CSS Classes

The InputOTP component uses these CSS classes (View source styles):

Base Classes [!toc]

  • .input-otp - Base container
  • .input-otp__container - Inner container from input-otp library
  • .input-otp__group - Group of slots
  • .input-otp__slot - Individual input slot
  • .input-otp__slot-value - The character inside a slot
  • .input-otp__caret - Blinking caret indicator
  • .input-otp__separator - Visual separator between groups

State Classes [!toc]

  • .input-otp__slot[data-active="true"] - Currently active slot
  • .input-otp__slot[data-filled="true"] - Slot with a character
  • .input-otp__slot[data-disabled="true"] - Disabled slot
  • .input-otp__slot[data-invalid="true"] - Invalid slot
  • .input-otp__container[data-disabled="true"] - Disabled container

Interactive States

The component supports both CSS pseudo-classes and data attributes for flexibility:

  • Hover: :hover or [data-hovered="true"] on slot
  • Active: [data-active="true"] on slot (currently focused)
  • Filled: [data-filled="true"] on slot (contains a character)
  • Disabled: [data-disabled="true"] on container and slots
  • Invalid: [data-invalid="true"] on slots

API Reference

InputOTP

InputOTP is built on top of the input-otp library with additional features.

Base Props

PropTypeDefaultDescription
maxLengthnumber-Required. Number of input slots.
valuestring-Controlled value (uncontrolled if not provided).
onChange(value: string) => void-Handler called when the value changes.
onComplete(value: string) => void-Handler called when all slots are filled.
classNamestring-Additional CSS classes for the container.
containerClassNamestring-CSS classes for the inner container.
variant"primary" | "secondary""primary"Visual variant of the component. primary is the default style with shadow. secondary is a lower emphasis variant without shadow, suitable for use in surfaces.
childrenReact.ReactNode-InputOTP.Group, InputOTP.Slot, and InputOTP.Separator components.

Validation Props

PropTypeDefaultDescription
isDisabledbooleanfalseWhether the input is disabled.
isInvalidbooleanfalseWhether the input is in an invalid state.
validationErrorsstring[]-Server-side or custom validation errors.
validationDetailsValidityState-HTML5 validation details.

Input Props

PropTypeDefaultDescription
patternstring-Regex pattern for allowed characters (e.g., REGEXP_ONLY_DIGITS).
textAlign'left' | 'center' | 'right''left'Text alignment within slots.
inputMode'numeric' | 'text' | 'decimal' | 'tel' | 'search' | 'email' | 'url''numeric'Virtual keyboard type on mobile devices.
placeholderstring-Placeholder text for empty slots.
pasteTransformer(text: string) => string-Transform pasted text (e.g., remove hyphens).

Form Props

PropTypeDefaultDescription
namestring-Name attribute for form submission.
autoFocusboolean-Whether to focus the first slot on mount.

InputOTP.Group

PropTypeDefaultDescription
classNamestring-Additional CSS classes for the group.
childrenReact.ReactNode-InputOTP.Slot components.

InputOTP.Slot

PropTypeDefaultDescription
indexnumber-Required. Zero-based index of the slot.
classNamestring-Additional CSS classes for the slot.

InputOTP.Separator

PropTypeDefaultDescription
classNamestring-Additional CSS classes for the separator.

Exported Patterns

HeroUI re-exports common regex patterns from input-otp for convenience:

import { REGEXP_ONLY_DIGITS, REGEXP_ONLY_CHARS, REGEXP_ONLY_DIGITS_AND_CHARS } from '@lenso/ui';
// Use with pattern prop<InputOTP pattern={REGEXP_ONLY_DIGITS} maxLength={6}>  {/* ... */}</InputOTP>
  • REGEXP_ONLY_DIGITS - Only numeric characters (0-9)
  • REGEXP_ONLY_CHARS - Only alphabetic characters (a-z, A-Z)
  • REGEXP_ONLY_DIGITS_AND_CHARS - Alphanumeric characters (0-9, a-z, A-Z)