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- Value
- 0 / 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
pnpm add @wh1teee/mui-otp-input @mui/material @emotion/react @emotion/styled'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.
pnpm add @wh1teee/mui-otp-input @base-ui/react'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.
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>.autoSubmitsubmits 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.
// 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.
// 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.
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>
);
}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
<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
<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 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.
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); // trueUse 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.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled value. |
defaultValue | string | '' | Initial value when uncontrolled. |
onChange | (value) => void | — | Every accepted change. |
onComplete | (value) => void | — | All slots are filled. |
length | number | 4 | Number 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. |
autoFocus | boolean | number | false | Focuses the first slot, optionally after a delay in ms. |
autoSubmit | boolean | false | Submits the owning form on completion. |
mask | boolean | false | Renders slots as password inputs. |
ariaLabel | string | — | Accessible name; slots read “ariaLabel 3/6”. |
slotAriaLabel | (index, length) => string | — | Custom accessible name per slot. |
TextFieldsProps | TextFieldProps | (index) => TextFieldProps | — | Props for every slot, or per slot. |
name, form, required | string, 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.
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | required | Visible label, linked to the first slot. |
length | number | required | Number of slots. |
helperText | ReactNode | — | Description below the slots; announced on error. |
error | boolean | false | Invalid styling and aria-invalid on every slot. |
value, defaultValue | string | — | 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. |
separatorAfter | readonly number[] | [] | Zero-based slots followed by a separator. |
classNames | OtpInputClassNames | — | root, label, group, slot, separator, description. |
slotProps | props | (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.
| Import | Provides | Peers |
|---|---|---|
@wh1teee/mui-otp-input | MuiOtpInput | MUI + Emotion |
…/mui | MuiOtpInput, explicit import | MUI + Emotion |
…/base-ui | OtpInput and InputOTP primitives | Base UI |
…/shadcn | The same, under shadcn naming | Base UI |
…/shadcn.css | Optional skin over data-slot | none |
…/headless | Normalization helpers | none |
…/mui/react-hook-form | MuiOtpInputController | MUI + React Hook Form |
…/base-ui/react-hook-form | OtpInputController | Base UI + React Hook Form |
…/shadcn/react-hook-form | OtpInputController | Base UI + React Hook Form |
SSR and Next.js
Works in the App Router through normal package exports — no transpilePackages or special config.
// 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.
gzip for the Material UI entry, peers external.
gzip for /base-ui and /shadcn.
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.