Skip to content
BigBang

Popular

    ↑↓Navigate↵OpenEscClose
    TR

    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.

    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.

    Live example, StorybookOpen in Storybook
    Live example, StorybookOpen in Storybook
    Terminal window
    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>
    );
    }
    1. An h1 “Enter the code” and the masked number the code went to.
    2. One TextInput: inputMode="numeric", autoComplete="one-time-code", up to 6 digits; pasted spaces and dashes are removed.
    3. A primary “Verify” Button.
    4. A “Resend code” button: focusable but disabled for 30 seconds, with the time left in its label.
    5. A “Use another method” Link and a polite status region.
    • 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-invalid and 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.

    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.