OtpInput
Enter a one-time code from an SMS — separate cells, paste, autofill and a resend timer.
+998 90 *** ** 67 ga yuborildi. Kod: 123456
'use client';
import { Field, OtpInput, toast } from '@akhatmirzo/ui';
import { useState } from 'react';
const CORRECT = '123456';
export default function OtpInputVerify() {
const [code, setCode] = useState('');
const [status, setStatus] = useState<'idle' | 'error' | 'ok'>('idle');
return (
<div className="flex flex-col gap-2">
<Field
label="Tasdiqlash kodi"
description={
status === 'ok' ? 'Kod tasdiqlandi' : `+998 90 *** ** 67 ga yuborildi. Kod: ${CORRECT}`
}
error={status === 'error' ? "Kod noto'g'ri. Qayta urinib ko'ring." : undefined}
>
<OtpInput
groupSize={3}
value={code}
disabled={status === 'ok'}
onValueChange={(value) => {
setCode(value);
if (status === 'error') setStatus('idle');
}}
onComplete={(value) => setStatus(value === CORRECT ? 'ok' : 'error')}
/>
</Field>
<OtpInput.ResendTimer
onResend={() => {
setCode('');
setStatus('idle');
toast('Yangi kod yuborildi');
}}
/>
</div>
);
}
Import
import { OtpInput, ResendTimer } from '@akhatmirzo/ui';ResendTimer is also available as OtpInput.ResendTimer, so a separate import is optional.
When to use
- Phone or email verification, two-factor sign-in — any 4–8 character code sent by SMS or an authenticator app.
- Verify as soon as the code is complete:
onCompletefires when the last character goes in, so you don't need a "Verify" button. - Explain errors in words:
invalidturns the cells red and shakes them, but put the reason in the Fielderrorprop ("Wrong code"). - Offer a resend with a timer —
ResendTimerasks users to wait for the SMS and keeps your server from being hammered. - Not for passwords or PINs you need to hide — the characters are shown in clear text.
Verification flow
In the example above the correct code is 123456. A wrong code switches on invalid: the cells
turn red and the group shakes gently. Clear the error as soon as the user starts typing again,
and disable the input once the code is accepted.
import { Field, OtpInput } from '@akhatmirzo/ui';
import { useState } from 'react';
async function verifyCode(code: string) {
const res = await fetch('/api/verify', { method: 'POST', body: JSON.stringify({ code }) });
return res.ok;
}
export function VerifyForm() {
const [code, setCode] = useState('');
const [error, setError] = useState(false);
return (
<Field label="Verification code" error={error ? 'Wrong code' : undefined}>
<OtpInput
value={code}
onValueChange={(v) => {
setCode(v);
setError(false);
}}
onComplete={async (v) => setError(!(await verifyCode(v)))}
/>
</Field>
);
}Paste and SMS autofill
Users can paste the whole code (Ctrl+V) into any cell and every cell fills up. The first cell
has autocomplete="one-time-code", so iOS and Android suggest the code from the SMS right above
the keyboard.
Length, type and grouping
length sets the number of cells (default 6). type="alphanumeric" accepts letters as well
(the default is digits only). groupSize splits the cells into groups: 3 → 123 – 456.
'use client';
import { OtpInput } from '@akhatmirzo/ui';
import { useState } from 'react';
export default function OtpInputLength() {
const [promo, setPromo] = useState('');
return (
<div className="flex flex-col items-start gap-5">
<OtpInput length={4} aria-label="4 xonali PIN" />
<OtpInput length={6} groupSize={2} aria-label="Juft guruhli kod" />
<div className="flex flex-col gap-2">
<OtpInput
type="alphanumeric"
groupSize={3}
aria-label="Harfli kod"
value={promo}
onValueChange={setPromo}
/>
<span className="text-sm text-text-muted">Qiymat: {promo || '—'}</span>
</div>
</div>
);
}
States
invalid adds a red border and a shake (no shake when prefers-reduced-motion is on). disabled
turns the cells off.
'use client';
import { OtpInput } from '@akhatmirzo/ui';
export default function OtpInputStates() {
return (
<div className="flex flex-col items-start gap-5">
<OtpInput invalid defaultValue="482913" groupSize={3} aria-label="Xato kod" />
<OtpInput disabled defaultValue="12" groupSize={3} aria-label="O'chirilgan" />
</div>
);
}
ResendTimer
"Kodni qayta yuborish (0:59)" — the button can't be clicked until the timer runs out. If
onResend returns a Promise, the button also stays blocked until it settles, then the timer
starts over. The countdown starts as soon as the component mounts, assuming the code has just been
sent.
'use client';
import { OtpInput, toast } from '@akhatmirzo/ui';
const sendSms = () =>
new Promise<void>((resolve) => {
setTimeout(() => {
toast('SMS qayta yuborildi');
resolve();
}, 800);
});
export default function OtpResendTimer() {
return (
<div className="flex flex-col items-start gap-3">
<OtpInput.ResendTimer seconds={5} onResend={() => toast('Kod qayta yuborildi')} />
<OtpInput.ResendTimer
seconds={3}
labels={{ resend: 'SMS ni qayta olish' }}
onResend={sendSms}
/>
</div>
);
}
Props
OtpInput
Accepts the remaining <div> attributes (className, aria-label, data-* ...) and ref (the
root is the group of cells).
| Prop | Type | Default | Description |
|---|---|---|---|
length | number | 6 | Number of cells |
type | 'numeric' | 'alphanumeric' | 'numeric' | Digits only, or letters and digits |
value | string | — | The code (controlled) |
defaultValue | string | — | |
onValueChange | (value: string) => void | — | |
onComplete | (code: string) => void | — | Called when the last character is entered |
invalid | boolean | — | Error: red + shake (automatic inside a Field with an error) |
disabled | boolean | — | |
groupSize | number | — | Grouping: 3 → 123 – 456 |
name | string | — | Name of the hidden form field |
autoFocus | boolean | — | Focus the first cell on mount |
labels | OtpInputLabels | — | { cell?: (index, length) => string; group?: string } |
ResendTimer
Accepts the remaining <button> attributes.
| Prop | Type | Default | Description |
|---|---|---|---|
seconds | number | 60 | Wait time in seconds |
onResend | () => unknown | — | Click handler; if it returns a Promise, the button waits for it |
labels | { resend?: string } | { resend: 'Kodni qayta yuborish' } | |
disabled | boolean | — | Blocks the button regardless of the timer |
Data attributes
| Attribute | Element | Value |
|---|---|---|
data-invalid | OtpInput | present when invalid |
data-disabled | OtpInput | present when disabled |
data-complete | OtpInput | present when all cells are filled |
data-state | ResendTimer | waiting · ready |
Keyboard
| Key | Action |
|---|---|
| A character | Fills the cell and moves to the next one |
Backspace | Deletes the character; in an empty cell, goes back one |
Delete | Deletes the character in the current cell |
← → | Move between cells |
Home / End | First cell / first empty cell |
You can't skip ahead past an empty cell — focus moves to the first empty one.
Accessibility
- The group has
role="group". Inside aFieldit's named by the label (aria-labelledby); outside, it's called "Tasdiqlash kodi" ("verification code", change it withlabels.group). - Each cell is its own
<input>with a name: "1-raqam", "2-raqam" ... For another language, uselabels.cell:cell: (i, n) => 'Digit ' + (i + 1) + ' of ' + n. aria-invalid,aria-describedbyandaria-requiredfrom theFieldreach every cell, so the error text is read whichever cell has focus.- Mobile keyboard: the
numerictype setsinputMode="numeric". - Motion: the shake is skipped with
prefers-reduced-motion: reduce. - ResendTimer uses
aria-disabledwhile waiting — it stays focusable but can't be activated; the countdown is part of the button's name.