OTP Input

PreviousNext

A polished one-time-password input with animated cells, a pulsing caret, spring-loaded digits, success glow and error shake.

Enter the 6-digit code (try 123456).

Sizes
1
2
3
4
5
6
Subtle, masked & disabled
4
2
•
•
•
7
7
8
8
"use client";

import { useState } from "react";

import { OtpInput } from "@/components/matos-ui/otp-input";

const DEMO_CODE = "123456";

export function OtpInputDemo() {
  const [value, setValue] = useState("");
  const [error, setError] = useState<string | undefined>(undefined);
  const [verified, setVerified] = useState(false);

  function handleChange(next: string) {
    setValue(next);
    if (error) {
      setError(undefined);
    }
    if (verified) {
      setVerified(false);
    }
  }

  function handleComplete(next: string) {
    if (next === DEMO_CODE) {
      setVerified(true);
      setError(undefined);
    } else {
      setError("Invalid code. Try 123456.");
      setVerified(false);
    }
  }

  return (
    <div className="flex w-full max-w-md flex-col gap-8">
      <OtpInput
        label="Verification code"
        description={
          verified
            ? "Code verified — welcome back."
            : "Enter the 6-digit code (try 123456)."
        }
        error={error}
        value={value}
        onChange={handleChange}
        onComplete={handleComplete}
        groupSize={3}
      />

      <div className="flex flex-col gap-3 border-border/60 border-t pt-6">
        <span className="text-muted-foreground text-xs">Sizes</span>
        <div className="flex flex-wrap items-end gap-6">
          <OtpInput size="sm" length={4} defaultValue="12" />
          <OtpInput size="md" length={4} defaultValue="34" />
          <OtpInput size="lg" length={4} defaultValue="56" />
        </div>
      </div>

      <div className="flex flex-col gap-3 border-border/60 border-t pt-6">
        <span className="text-muted-foreground text-xs">
          Subtle, masked & disabled
        </span>
        <div className="flex flex-wrap items-center gap-6">
          <OtpInput variant="subtle" length={5} defaultValue="42" />
          <OtpInput mask length={5} defaultValue="903" />
          <OtpInput disabled length={4} defaultValue="7788" />
        </div>
      </div>
    </div>
  );
}

Installation

pnpm dlx shadcn@latest add https://matos-ui.com/r/otp-input.json

Usage

import { OtpInput } from '@/components/matos-ui/otp-input'
<OtpInput
  label="Verification code"
  length={6}
  onComplete={(code) => verify(code)}
/>

When to use

Use the OTP Input for short, fixed-length codes: two-factor authentication, email or SMS verification, and PIN entry. It behaves like a single field — typing, pasting, autofill (autocomplete="one-time-code") and keyboard navigation all work out of the box — while rendering one animated cell per character.

Examples

Controlled with validation

Drive the value yourself and react to onComplete to verify the code. Setting error turns the cells destructive and triggers a subtle shake.

const [value, setValue] = useState('')
const [error, setError] = useState<string>()

<OtpInput
  label="Verification code"
  value={value}
  onChange={setValue}
  error={error}
  onComplete={(code) => {
    if (code !== '123456') setError('Invalid code.')
  }}
/>

Grouping

Use groupSize to split the cells with separators (for example XXX-XXX).

<OtpInput length={6} groupSize={3} />

Masked PIN

Hide the characters behind dots for sensitive PINs.

<OtpInput type="numeric" mask length={4} />

Alphanumeric

Accept letters and numbers for invite or backup codes.

<OtpInput type="alphanumeric" length={8} />

States & micro-interactions

  • Entrance — cells fade and rise into place with a staggered delay.
  • Active cell — the focused empty cell lifts slightly, gains a ring and shows a pulsing caret.
  • Digit entry — each character pops in with a spring and a soft blur, and animates out on delete.
  • Complete — a chart-2 glow sweeps over the group when every cell is filled.
  • Error — destructive borders plus a horizontal shake on the whole group.
  • Reduced motion — all motion is disabled when the user prefers reduced motion.

Reference

OtpInput Props

PropTypeDefaultDescription
lengthnumber6Number of cells / characters.
valuestring—Controlled value.
defaultValuestring""Initial value when uncontrolled.
onChange(value: string) => void—Called whenever the value changes.
onComplete(value: string) => void—Called once when all cells are filled.
type"numeric" | "alphanumeric" | "text""numeric"Allowed characters and input mode.
maskbooleanfalseRender dots instead of the typed characters.
groupSizenumber—Insert a separator after every N cells.
size"sm" | "md" | "lg""md"Cell size.
variant"default" | "subtle""default"Visual style of the cells.
labelReactNode—Field label.
descriptionReactNode—Helper text below the field.
errorReactNode—Error message; marks the field invalid and shakes it.
invalidbooleanfalseForce the invalid state without a message.
disabledbooleanfalseDisable the input.

Also exported: otpInputVariants and the OtpInputProps / OtpInputType types.

Best practices

  • Keep codes short (4–8 characters); OTP is not meant for long secrets.
  • Prefer type="numeric" for SMS/email codes so mobile keyboards show digits.
  • Always provide a label (or rely on the built-in aria-label) for screen readers.
  • Surface verification results through error / description text, not color alone.