Skip to content

One-time codes, typed, pasted, or autofilled

An accessible OTP input for React. Every way a code can arrive lands in one clean string. Use the Material UI component, the shadcn-styled Base UI field, or the primitives inside your own design system.

npm i @wh1teee/mui-otp-input
Renderer
Value
empty0 / 6

Paste a whole message — only the digits land.Your verification code is 482-913

Quick start

React 18 or 19. Install the package with the peers of the renderer you use — each renderer needs only its own.

Material UI

Terminal
pnpm add @wh1teee/mui-otp-input @mui/material @emotion/react @emotion/styled
VerificationCode.tsx
'use client';

import { MuiOtpInput } from '@wh1teee/mui-otp-input';
import { useState } from 'react';

export function VerificationCode() {
  const [code, setCode] = useState('');

  return (
    <MuiOtpInput
      ariaLabel="Verification code"
      length={6}
      value={code}
      onChange={setCode}
      onComplete={(value) => verify(value)}
      validationType="numeric"
      pastePreprocess="digits-only"
    />
  );
}

Material UI 7 and 9 are supported. The value is always a plain string with no separators or placeholders, ready to send to your API.

Base UI and shadcn

A complete field built on Base UI's OTP Field. Neither path loads Material UI or Emotion.

Terminal
pnpm add @wh1teee/mui-otp-input @base-ui/react
VerificationCode.tsx
'use client';

import { OtpInput } from '@wh1teee/mui-otp-input/shadcn';
import '@wh1teee/mui-otp-input/shadcn.css';
import { useState } from 'react';

export function VerificationCode() {
  const [code, setCode] = useState('');

  return (
    <OtpInput
      label="Verification code"
      helperText="Enter the six digits we sent to your phone."
      length={6}
      separatorAfter={[2]}
      value={code}
      onValueChange={setCode}
    />
  );
}

OtpInput wires the label, description, error state, and an accessible name for every slot. /shadcn is the same component under shadcn naming; import /shadcn.css for a skin built on the standard shadcn variables, or style the data-slot attributes yourself.

Compose the primitives

When your design system already owns the field layout, build it from the parts shadcn users know.

CodeField.tsx
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from '@wh1teee/mui-otp-input/shadcn';

<label htmlFor="code">Verification code</label>
<InputOTP id="code" length={6} value={code} onValueChange={setCode}>
  <InputOTPGroup>
    <InputOTPSlot index={0} aria-label="Digit 1 of 6" />
    <InputOTPSlot index={1} aria-label="Digit 2 of 6" />
    <InputOTPSlot index={2} aria-label="Digit 3 of 6" />
  </InputOTPGroup>
  <InputOTPSeparator />
  <InputOTPGroup>
    <InputOTPSlot index={3} aria-label="Digit 4 of 6" />
    <InputOTPSlot index={4} aria-label="Digit 5 of 6" />
    <InputOTPSlot index={5} aria-label="Digit 6 of 6" />
  </InputOTPGroup>
</InputOTP>

Value and focus

Every renderer follows the same rules, so switching renderers never changes what your form receives.

  • The value is one string, clamped to length. Controlled and default values pass through the same rules as typing.
  • Focus advances after each accepted character. Clicking a later empty slot focuses the first empty one, so a code is never entered with gaps.
  • Backspace deletes and steps back; Delete, the arrow keys, Home, and End move through the slots as in a single text field. Tab moves past the whole code, not through every slot.
  • One slot holds one character. Emoji and other characters outside the Basic Multilingual Plane are rejected, and IME text commits once composition ends.
  • With name, the code submits and resets with its native <form>. autoSubmit submits the owning form on completion; it is off by default.

Character rules

A built-in policy, an optional transform, and an optional validator, applied to every character.

Rules.tsx
// Letters and digits, shown upper-case.
<MuiOtpInput
  length={8}
  validationType="alphanumeric"
  transformChar={(character) => character.toUpperCase()}
/>

// Reject look-alike characters on top of the built-in policy.
<MuiOtpInput
  validationType="alphanumeric"
  validateChar={(character) => !/[0OIl1]/.test(character)}
  onInvalid={(attempt, { reason }) => explain(reason, attempt)}
/>

transformChar runs before validation, so it can turn an otherwise rejected character into an accepted one. Rejected input never reaches the value; onInvalid reports it so you can explain why. Transforms apply to what users type, paste, or autofill — a value you pass in is only checked, never transformed again.

Paste and autofill

A full code can arrive at once — pasted from a message or offered by the keyboard.

Paste.tsx
// Built-in strategies
<MuiOtpInput pastePreprocess="digits-only" /> // "Code: 12-34" → "1234"
<MuiOtpInput pastePreprocess="trim" />        // " ab12 " → "ab12"

// Or your own: pull the code out of a longer message.
<MuiOtpInput
  pastePreprocess={(text) => text.match(/\b\d{6}\b/)?.[0] ?? text}
/>

The first slot advertises autocomplete="one-time-code", so iOS and Android suggest codes from incoming messages; the other slots opt out. A pasted or autofilled code replaces the current value in one step.

React Hook Form

Typed controllers instead of hand-written Controller wiring. Install react-hook-form only when you import them.

VerifyForm.tsx
import { useForm } from 'react-hook-form';
import { OtpInputController } from '@wh1teee/mui-otp-input/shadcn/react-hook-form';

type Values = { code: string };

export function VerifyForm() {
  const { control, handleSubmit } = useForm<Values>({
    defaultValues: { code: '' },
  });

  return (
    <form onSubmit={handleSubmit(submit)}>
      <OtpInputController
        control={control}
        name="code"
        label="Verification code"
        length={6}
        rules={{
          validate: (value) => value.length === 6 || 'Enter all six digits',
        }}
      />
      <button type="submit">Verify</button>
    </form>
  );
}
Material UI
import { MuiOtpInputController } from '@wh1teee/mui-otp-input/mui/react-hook-form';

<MuiOtpInputController
  control={control}
  name="code"
  ariaLabel="Verification code"
  length={6}
  validationType="numeric"
  rules={{ required: 'Enter the code' }}
/>;

Both controllers register the first slot as the field ref, so setFocus, focus on error, reset, disabled fields, and server errors behave like any other input. Field names are limited to string-valued paths at the type level.

Styling

Material UI styling for the MUI component; plain classes and data attributes for Base UI.

Material UI

Styled.tsx
<MuiOtpInput
  length={6}
  sx={{ gap: 1.5, maxWidth: 420 }}
  TextFieldsProps={(index) => ({
    size: 'small',
    placeholder: '·',
    sx: index === 2 ? { mr: 2 } : undefined,
  })}
/>

// Theme overrides keep working through stable class names:
// .MuiOtpInput-Box, .MuiOtpInput-TextField, .MuiOtpInput-TextField-{n}

The root accepts every Box prop, and TextFieldsProps styles all slots at once or each slot on its own.

Base UI and shadcn

Tailwind.tsx
<OtpInput
  label="Code"
  length={6}
  classNames={{
    root: 'grid gap-2',
    group: 'flex gap-2',
    slot: 'size-12 rounded-md border text-center text-lg',
  }}
/>

Every part carries a data-slot attribute (otp-field, input-otp-group, input-otp-slot, …). The optional stylesheet targets only those attributes, adds no reset, and reads these tokens:

tokens.css
/* Tokens the optional skin reads. Define them where your theme lives. */
:root {
  --background: #ffffff;
  --foreground: #0f172a;
  --input: #cbd5e1;
  --ring: #64748b;
  --destructive: #b91c1c;
  --muted-foreground: #475569;
  --radius: 0.5rem;
}

Headless helpers

The normalization rules as plain functions, with no React or UI dependency.

verify.ts
import {
  isOtpComplete,
  normalizeOtpValue,
  preprocessOtpPaste,
} from '@wh1teee/mui-otp-input/headless';

// The same rules the components use, for route handlers and tests.
const pasted = preprocessOtpPaste('Your code is 12-34', 'digits-only');
const code = normalizeOtpValue(pasted, { length: 4, validationType: 'numeric' });

isOtpComplete(code, 4); // true

Use them to normalize input in route handlers, check values in tests, or drive a fully custom renderer.

MuiOtpInput

From the package root or /mui. Also accepts every Material UI Box prop.

PropTypeDefaultDescription
valuestring—Controlled value.
defaultValuestring''Initial value when uncontrolled.
onChange(value) => void—Every accepted change.
onComplete(value) => void—All slots are filled.
lengthnumber4Number of slots and maximum value length.
validationType'none' | 'numeric' | 'alphanumeric''none'Built-in character policy.
transformChar(char, index) => string—Maps a character before validation.
validateChar(char, index) => boolean—Extra per-character rule.
pastePreprocess'none' | 'trim' | 'digits-only' | fn'none'Cleans pasted and autofilled text.
onInvalid(attempt, details) => void—Typed or pasted input was rejected.
onBlur(value, complete) => void—Focus left the whole field.
autoFocusboolean | numberfalseFocuses the first slot, optionally after a delay in ms.
autoSubmitbooleanfalseSubmits the owning form on completion.
maskbooleanfalseRenders slots as password inputs.
ariaLabelstring—Accessible name; slots read “ariaLabel 3/6”.
slotAriaLabel(index, length) => string—Custom accessible name per slot.
TextFieldsPropsTextFieldProps | (index) => TextFieldProps—Props for every slot, or per slot.
name, form, requiredstring, string, boolean—Native form participation.

OtpInput

From /base-ui or /shadcn. Also accepts Base UI OTP Field root props such as name, disabled, and autoComplete.

PropTypeDefaultDescription
labelReactNoderequiredVisible label, linked to the first slot.
lengthnumberrequiredNumber of slots.
helperTextReactNode—Description below the slots; announced on error.
errorbooleanfalseInvalid styling and aria-invalid on every slot.
value, defaultValuestring—Controlled or initial value.
onValueChange(value, details) => void—Every accepted change.
onValueComplete(value, details) => void—All slots are filled.
validationType'none' | 'numeric' | 'alphanumeric''numeric'Built-in character policy.
separatorAfterreadonly number[][]Zero-based slots followed by a separator.
classNamesOtpInputClassNames—root, label, group, slot, separator, description.
slotPropsprops | (index) => props—Native and Base UI props per slot.
slotAriaLabel(index, length) => string—Custom accessible name per slot.

InputOTP adds the same behavior props (transformChar, validateChar, pastePreprocess, autoFocus) to Base UI's OTPField.Root; InputOTPSlot requires an index.

Entrypoints

Renderer and form peers are optional. A missing peer fails only when its entrypoint is imported.

ImportProvidesPeers
@wh1teee/mui-otp-inputMuiOtpInputMUI + Emotion
…/muiMuiOtpInput, explicit importMUI + Emotion
…/base-uiOtpInput and InputOTP primitivesBase UI
…/shadcnThe same, under shadcn namingBase UI
…/shadcn.cssOptional skin over data-slotnone
…/headlessNormalization helpersnone
…/mui/react-hook-formMuiOtpInputControllerMUI + React Hook Form
…/base-ui/react-hook-formOtpInputControllerBase UI + React Hook Form
…/shadcn/react-hook-formOtpInputControllerBase UI + React Hook Form

SSR and Next.js

Works in the App Router through normal package exports — no transpilePackages or special config.

Next.js App Router
// app/verify/page.tsx — a Server Component
import { VerificationCode } from './verification-code'; // 'use client' inside

export default function VerifyPage() {
  return <VerificationCode />;
}

// app/api/verify/route.ts — headless helpers run anywhere
import { normalizeOtpValue } from '@wh1teee/mui-otp-input/headless';

UI entrypoints ship the "use client" directive and render on the server. Keep the initial value identical on the server and the first client render. A one-time code is a credential: don't log it, and don't keep it after verification.

Accessibility

Targets WCAG 2.2 AA, checked with axe and keyboard tests in Chromium, Firefox, and WebKit.

Every slot announces its position (“Verification code 3/6”), and in the Base UI field errors reach every slot through aria-describedby and aria-invalid. Slots grow to touch-target size on coarse pointers, and the skin honors forced colors and reduced motion. When you compose primitives, give the first slot a visible label and every slot an aria-label.

Size and quality

Every pull request checks the exact package tarball that would be published.

≤ 4.7 KB

gzip for the Material UI entry, peers external.

≤ 3.65 KB

gzip for /base-ui and /shadcn.

≤ 1 KB

gzip for /headless, with no runtime peer.

CI installs the packed tarball into isolated MUI 7 + React 18, MUI 9 + React 19, Base UI–only, and Next.js 16 consumers, and fails if a Material UI import leaks into a Base UI bundle or the reverse. Releases are published from GitHub Actions with npm provenance. This project continues viclafouch/mui-otp-input under the MIT license — see the migration guide and the third-party notices.