Templates
One-time code
Code entry in one numeric field with paste support, a resend cooldown and the attempts left.
This block takes the one-time code asked for after sign-in, inside AuthShell. Its rules come from the Session timeout and one-time code pattern.
Preview
Section titled “Preview”The sample code is 123456; any other six digits count as a wrong attempt. “Resend code” becomes available after 30 seconds. The brand and mode switches in the page header restyle the preview too.
pnpm add @bigbang/ui| Data | Type | Note |
|---|---|---|
onVerify |
(code: string) => Promise<boolean> |
Returns true for a correct code. |
attempts |
number |
Attempts left; a wrong code takes one off. Defaults to 3. |
initialError |
boolean |
Opens after a rejected attempt. |
import { Button, Link, Stack, Text, TextInput } from '@bigbang/ui';import { useEffect, useState, type ClipboardEvent, type FormEvent } from 'react';import type { Lang } from '../../patterns/lang';import { AuthShell } from '../../templates/auth-shell/AuthShell';
const copy = { tr: { title: 'Kodu girin', description: 'Kod, ***** 12 ile biten telefona gönderildi.', label: 'Doğrulama kodu', hint: '6 haneli kodu girin.', verify: 'Doğrula', verifying: 'Doğrulanıyor', resend: 'Kodu tekrar gönder', resendWait: (seconds: number) => `Kodu tekrar gönder (${seconds} sn)`, resent: 'Yeni kod gönderildi.', other: 'Başka bir yöntem kullan', short: 'Kod 6 haneli olmalı.', wrong: (left: number) => `Kod hatalı. ${left} deneme hakkınız kaldı.`, }, en: { title: 'Enter the code', description: 'We sent the code to the phone ending in ***** 12.', label: 'Verification code', hint: 'Enter the 6-digit code.', verify: 'Verify', verifying: 'Verifying', resend: 'Resend code', resendWait: (seconds: number) => `Resend code (${seconds} s)`, resent: 'A new code was sent.', other: 'Use another method', short: 'The code must be 6 digits.', wrong: (left: number) => `Wrong code. You have ${left} ${left === 1 ? 'attempt' : 'attempts'} left.`, },};
export const OTP_COOLDOWN = 30;const digitsOf = (value: string) => value.replace(/\D/g, '').slice(0, 6);
export type OtpBlockProps = { lang?: Lang; /** Attempts left; the error after a wrong code counts down from here. */ attempts?: number; /** Starts with a wrong-code error, as after a rejected attempt. */ initialError?: boolean; /** Resolves `true` for a correct code. */ onVerify?: (code: string) => Promise<boolean>;};
const demoVerify = (code: string) => new Promise<boolean>((resolve) => setTimeout(() => resolve(code === '123456'), 800));
/** One-time code screen: a single numeric field (paste-friendly), a resend cooldown and a fallback link. */export function OtpBlock({ lang = 'tr', attempts = 3, initialError = false, onVerify = demoVerify }: OtpBlockProps) { const t = copy[lang]; const [code, setCode] = useState(''); const [left, setLeft] = useState(initialError ? attempts - 1 : attempts); const [error, setError] = useState(initialError ? t.wrong(attempts - 1) : ''); const [cooldown, setCooldown] = useState(OTP_COOLDOWN); const [status, setStatus] = useState(''); const [pending, setPending] = useState(false);
useEffect(() => { if (cooldown <= 0) return; const timer = setTimeout(() => setCooldown((value) => value - 1), 1000); return () => clearTimeout(timer); }, [cooldown]);
const onPaste = (event: ClipboardEvent<HTMLInputElement>) => { event.preventDefault(); setCode(digitsOf(event.clipboardData.getData('text'))); };
const onSubmit = async (event: FormEvent) => { event.preventDefault(); if (code.length !== 6) { setError(t.short); return; } setPending(true); const ok = await onVerify(code); setPending(false); if (ok) { setError(''); return; } const next = Math.max(left - 1, 0); setLeft(next); setError(t.wrong(next)); };
const resend = () => { if (cooldown > 0) return; setCooldown(OTP_COOLDOWN); setStatus(t.resent); };
return ( <AuthShell lang={lang} title={t.title} description={t.description}> <form noValidate onSubmit={onSubmit} aria-labelledby={`auth-title-${lang}`}> <Stack gap="lg"> <TextInput label={t.label} description={t.hint} name="code" inputMode="numeric" autoComplete="one-time-code" pattern="[0-9]*" maxLength={6} size="lg" className="[&_input]:tracking-[0.5em] [&_input]:tabular-nums" value={code} onValueChange={(value) => setCode(digitsOf(value))} onPaste={onPaste} {...(error ? { error } : {})} /> <Button type="submit" fullWidth loading={pending} loadingLabel={t.verifying}> {t.verify} </Button> <Stack gap="sm" align="start"> <Button variant="tertiary" size="sm" focusableWhenDisabled disabled={cooldown > 0} onClick={resend}> {cooldown > 0 ? t.resendWait(cooldown) : t.resend} </Button> <Link href="#" size="sm"> {t.other} </Link> </Stack> <Text size="sm" tone="subtle" role="status"> {status} </Text> </Stack> </form> </AuthShell> );}Structure
Section titled “Structure”- An
h1“Enter the code” and the masked number the code went to. - One TextInput:
inputMode="numeric",autoComplete="one-time-code", up to 6 digits; pasted spaces and dashes are removed. - A primary “Verify” Button.
- A “Resend code” button: focusable but disabled for 30 seconds, with the time left in its label.
- A “Use another method” Link and a polite status region.
Accessibility
Section titled “Accessibility”- The code sits in one field, so screen readers read one field instead of six boxes, and autofill and paste work.
- On a wrong code the field gets
aria-invalidand the error, with the attempts left, is read with the field. - The resend button stays focusable during the cooldown (
aria-disabled); its label tells the user why. - “A new code was sent.” is read once from a polite status region.
- The countdown is not announced every second; only the label updates.
Components used
Section titled “Components used”TextInput, Button, Link, Text. Upcoming components: OtpInput (C4) will add a six-box look over the same single field; only the field changes in this block.