SuperAPI AI Directory

shadcn-syntax-input-otp

Frontend & websites

View repository

Updated 10-15 22:18

About this skill

shadcn ui : Input OTP primitive

The InputOTP family wraps the input-otp library by guilhermerodz and renders a one-time-password input as a row of fixed-width slots that mimic individual character boxes while remaining a single accessible text input under the hood. Every claim in this skill traces to the canonical source at apps/v4/registry/new-york-v4/ui/input-otp.tsx in shadcn-ui/ui, the official docs at https://ui.shadcn.com/docs/components/radix/input-otp, and the upstream library at https://github.com/guilhermerodz/input-otp.

Quick Reference

Primitive Wraps Role
InputOTP OTPInput from input-otp Root container, owns maxLength, value, onChange, pattern, onComplete, autoComplete, disabled
InputOTPGroup plain div Visual group of consecutive slots ; combine multiple groups with InputOTPSeparator between them
InputOTPSlot plain div reading from OTPInputContext One visible character box, requires index: number matching its position 0 to maxLength - 1
InputOTPSeparator plain div with role="separator" and a MinusIcon Visual divider between two InputOTPGroup instances

Install : pnpm dlx shadcn@latest add input-otp (CLI copies components/ui/input-otp.tsx and installs the input-otp peer dependency).

When to use this skill

Use InputOTP for any flow where the user must enter a short, fixed-length code character-by-character with visible per-character boxes :

  • Email verification codes (typically 6 digits)
  • SMS-delivered second-factor codes (typically 6 digits, SMS autofill expected)
  • Authenticator-app TOTP codes (6 digits)
  • Backup-code entry (often 8 to 10 alphanumeric)
  • PIN entry on payment or unlock flows (typically 4 to 6 digits)

Do NOT use InputOTP for free-text fields, passwords, or any input where the length is unknown ; use Input from shadcn-syntax-form or the underlying <input> element instead.

The four primitives and how they compose

"use client"

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
  InputOTPSeparator,
} from "@/components/ui/input-otp"
import { REGEXP_ONLY_DIGITS } from "input-otp"

export function SixDigitCode() {
  return (
    <InputOTP maxLength={6} pattern={REGEXP_ONLY_DIGITS}>
      <InputOTPGroup>
        <InputOTPSlot index={0} />
        <InputOTPSlot index={1} />
        <InputOTPSlot index={2} />
      </InputOTPGroup>
      <InputOTPSeparator />
      <InputOTPGroup>
        <InputOTPSlot index={3} />
        <InputOTPSlot index={4} />
        <InputOTPSlot index={5} />
      </InputOTPGroup>
    </InputOTP>
  )
}

Three rules govern composition :

  1. The number of InputOTPSlot instances rendered inside InputOTP MUST equal maxLength. If you render fewer slots, characters typed beyond the last visible slot still enter the underlying input but the user cannot see them. If you render more, the extra slots read undefined from context and render empty forever.
  2. Each InputOTPSlot MUST receive a unique index prop matching its position. index={0} is the leftmost slot, index={maxLength - 1} is the rightmost. The slot uses this index to read { char, hasFakeCaret, isActive } from OTPInputContext.
  3. InputOTPSeparator belongs BETWEEN two InputOTPGroup elements, not inside one. It is a sibling of groups, not a sibling of slots.

The 'use client' directive

The shadcn input-otp.tsx file begins with "use client" because InputOTPSlot calls React.useContext(OTPInputContext) and OTPInput itself uses hooks for caret animation. In a Next.js App Router project the file is already a client component ; any consuming page or layout that imports it stays a Server Component, which is fine. If you re-export InputOTP from a barrel file in a Server Component, the directive on the original file still applies. Do NOT remove the directive when customizing the file.

maxLength : the slot contract

maxLength is the single most important prop. It tells the underlying OTPInput how many characters the user may type and it implicitly defines how many InputOTPSlot instances you MUST render. Common values :

  • 4 : short PIN
  • 6 : standard email or SMS verification code
  • 8 : backup code
  • 10 : long backup code or recovery code

If you omit maxLength, OTPInput renders nothing usable and the user sees an empty box with no slots ; this is the single most common cause of "the OTP component is broken" bug reports.

pattern : restricting allowed characters

pattern accepts a regex source string (not a JS RegExp object) that defines which characters the user may type. The library exports three pre-built constants you should prefer over hand-written patterns :

Constant Source pattern Use for
REGEXP_ONLY_DIGITS ^\d+$ Numeric codes (SMS, email, TOTP, PIN)
REGEXP_ONLY_DIGITS_AND_CHARS ^[a-zA-Z0-9]+$ Alphanumeric backup codes, license keys
REGEXP_ONLY_CHARS ^[a-zA-Z]+$ Letter-only codes (rare)

Import from the library : import { REGEXP_ONLY_DIGITS } from "input-otp". The pattern is applied PER KEYSTROKE : a character that does not match is silently rejected. The pattern also applies to pasted strings : a paste containing any rejected character is rejected as a whole.

If you must hand-write a pattern, supply the regex source as a string :

<InputOTP maxLength={6} pattern="^[0-9]+$">...</InputOTP>

Do NOT pass a RegExp object literal. pattern={/^[0-9]+$/} will not behave as you expect because the library re-constructs the regex from a string internally.

value and onChange : controlled pattern

The canonical controlled pattern :

const [value, setValue] = React.useState("")

return (
  <InputOTP maxLength={6} value={value} onChange={setValue}>
    ...
  </InputOTP>
)

value is always a string with length between 0 and maxLength. onChange fires with the new string on every keystroke. The component is also valid uncontrolled (drop value and onChange) and exposes the value through the underlying input's standard form submission.

onComplete : reacting to a finished code

onComplete fires exactly once when the user types or pastes the final character that brings the value's length to maxLength. Use it to auto-submit the form or fire the verification request without requiring a separate submit click :

<InputOTP
  maxLength={6}
  value={value}
  onChange={setValue}
  onComplete={(code) => verifyCode(code)}
>
  ...
</InputOTP>

onComplete receives the full code string. It does NOT fire on every value change ; only on the transition to value.length === maxLength.

autoComplete="one-time-code" : SMS autofill

On iOS Safari and Android Chrome, setting autoComplete="one-time-code" on an input that the system detects as an OTP field unlocks two behaviours :

  1. The keyboard offers a suggestion chip with the code parsed from a recently received SMS.
  2. The browser may auto-fill the field without user interaction in some flows.

OTPInput forwards autoComplete to the underlying single hidden <input>. Without this prop, neither autofill path triggers and your users will type the code manually even when their phone received it 200 milliseconds ago.

<InputOTP maxLength={6} autoComplete="one-time-code">
  ...
</InputOTP>

Paste : a full code in one operation

input-otp rewrites the underlying paste handler to accept a clipboard string of up to maxLength characters and distribute it across slots in one operation. The user CAN paste a full 6-digit code from their clipboard onto the first slot (or any slot) and every slot fills correctly. The pattern constraint applies to the pasted string as a whole : a paste that contains any rejected character is rejected entirely.

No additional configuration is required. Paste works out of the box on desktop and mobile.

data-active and aria-invalid : styling hooks

Two attributes drive the default Tailwind classes on InputOTPSlot :

  • data-active="true" : applied to the slot that is currently focused. Drives the ring and z-index lift in the default classes.
  • aria-invalid="true" : when you place this on InputOTP (or the underlying input via custom render), the destructive border and ring colours apply automatically.
<InputOTP maxLength={6} aria-invalid={hasError}>
  ...
</InputOTP>

The default styles use aria-invalid:border-destructive, data-[active=true]:border-ring, and data-[active=true]:ring-ring/50 ; customize by passing className to individual InputOTPSlot instances.

disabled and has-disabled

disabled on InputOTP forwards to the underlying input and triggers two style hooks :

  • disabled:cursor-not-allowed on the input itself
  • has-disabled:opacity-50 on the container (the has- variant requires Tailwind v3.4 or v4)
<InputOTP maxLength={6} disabled={isVerifying}>
  ...
</InputOTP>

Form integration : use Controller, not register

InputOTP is a controlled component that exposes value and onChange, not a raw <input>. The react-hook-form register() API cannot drive it correctly because register expects a native input ref and target.value flow. ALWAYS use the Controller component to bridge :

import { Controller, useForm } from "react-hook-form"

const { control, handleSubmit } = useForm<{ code: string }>({
  defaultValues: { code: "" },
})

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    <Controller
      control={control}
      name="code"
      rules={{ minLength: 6, maxLength: 6 }}
      render={({ field }) => (
        <InputOTP
          maxLength={6}
          value={field.value}
          onChange={field.onChange}
          onBlur={field.onBlur}
        >
          <InputOTPGroup>
            {[0, 1, 2, 3, 4, 5].map((i) => (
              <InputOTPSlot key={i} index={i} />
            ))}
          </InputOTPGroup>
        </InputOTP>
      )}
    />
  </form>
)

When wrapping InputOTP with the higher-level FormField from shadcn-syntax-form, the same Controller wiring happens for you ; you pass render={({ field }) => <InputOTP {...field}>...</InputOTP>} and FormField injects Controller behind the scenes.

What this skill does NOT cover

  • The FormField + FormItem + FormControl + FormMessage composition that adds label and error wiring around InputOTP. See shadcn-syntax-form.
  • End-to-end form validation with zod resolvers, async server validation, and submit handlers. See shadcn-impl-form-validation.
  • The full underlying input-otp render prop (used only when fully replacing the shadcn slot rendering). The shadcn registry already wraps OTPInput with data-slot markup, so the render prop is not exposed at the InputOTP layer.

References

  • Primitive signatures : references/methods.md
  • Working examples (6-digit numeric, 4-digit PIN with separator, alphanumeric, controlled with onComplete, Controller integration, full MFA verify flow) : references/examples.md
  • Anti-patterns and how to fix them : references/anti-patterns.md

Companion skills

  • shadcn-syntax-form : the Form composition layer (FormField, FormItem, FormLabel, FormControl, FormMessage) that wraps InputOTP with react-hook-form context and label or error wiring.
  • shadcn-impl-form-validation : end-to-end recipes for form validation with zod, submit handlers, server actions, and async verification flows that consume the code from InputOTP.