Skip to content
BigBang

Popular

    ↑↓Navigate↵OpenEscClose
    TR

    Patterns

    Session timeout and one-time code

    How to warn before a session ends and how to design an accessible one-time code input.

    A timed session and a one-time code put the user under time pressure. This pattern defines when the warning comes, how the time is announced and how the code field behaves.

    • On every session that ends after inactivity: warn before it ends.
    • On entering a one-time code sent by text message or email.
    • On any time limit that falls under WCAG 2.2.1 and is not set by the user.

    The session warning starts at 125 seconds: after 5 seconds a dialog that cannot be dismissed opens and announces at 120, 60 and 30 seconds. Extend it: the time returns to the full session and a Toast appears.

    Live example, StorybookOpen in Storybook
    Your session is active.
    import { Button, Dialog, ProgressBar, ResultState, Stack, Text, useToast } from '@bigbang/ui';
    import { useEffect, useRef, useState } from 'react';
    import { type Lang, LangProvider } from '../lang';
    const copy = {
    tr: {
    heading: 'Oturumunuz sona ermek üzere',
    remaining: 'Kalan süre',
    body: 'Devam etmek için oturumu uzatın. Girdiğiniz bilgiler korunur.',
    extend: 'Oturumu uzat',
    signOut: 'Çıkış yap',
    active: 'Oturumunuz açık.',
    extended: 'Oturum 15 dakika uzatıldı',
    expiredTitle: 'Oturum süresi doldu',
    expired: 'Devam etmek için tekrar giriş yapın. Gönderilmemiş girdiğiniz bilgiler korunur.',
    signedOut: 'Çıkış yaptınız.',
    announce: (time: string) => `Oturumunuz ${time} sonra sona erecek`,
    format: (m: number, s: number) => (m > 0 ? `${m} dk ${s} sn` : `${s} sn`),
    human: (m: number, s: number) => [m > 0 ? `${m} dakika` : '', s > 0 || m === 0 ? `${s} saniye` : ''].filter(Boolean).join(' '),
    },
    en: {
    heading: 'Your session is about to end',
    remaining: 'Time left',
    body: 'Extend the session to continue. Your input is kept.',
    extend: 'Stay signed in',
    signOut: 'Sign out',
    active: 'Your session is active.',
    extended: 'Session extended by 15 minutes',
    expiredTitle: 'The session has timed out',
    expired: 'Sign in again to continue. Input you have not sent is kept.',
    signedOut: 'You are signed out.',
    announce: (time: string) => `Your session ends in ${time}`,
    format: (m: number, s: number) => (m > 0 ? `${m} min ${s} s` : `${s} s`),
    human: (m: number, s: number) =>
    [m > 0 ? `${m} ${m === 1 ? 'minute' : 'minutes'}` : '', s > 0 || m === 0 ? `${s} ${s === 1 ? 'second' : 'seconds'}` : ''].filter(Boolean).join(' '),
    },
    };
    /** Seconds before the end at which the warning appears (WCAG 2.2.1: at least 20 s to respond; 120 s here). */
    export const WARN_AT = 120;
    /** Remaining times, in seconds, that are announced. The visible countdown is not a live region. */
    export const ANNOUNCE_AT = [120, 60, 30] as const;
    /** Length of a full session in seconds. */
    export const SESSION_SECONDS = 900;
    export const formatRemaining = (seconds: number, lang: Lang) => copy[lang].format(Math.floor(seconds / 60), seconds % 60);
    /** The spoken form of the remaining time, such as "1 dakika 30 saniye". */
    export const humanRemaining = (seconds: number, lang: Lang) => copy[lang].human(Math.floor(seconds / 60), seconds % 60);
    function SessionCountdownDemo({ lang, startAt }: { lang: Lang; startAt: number }) {
    const t = copy[lang];
    const toast = useToast();
    const [remaining, setRemaining] = useState(startAt);
    const [signedOut, setSignedOut] = useState(false);
    const [announcement, setAnnouncement] = useState('');
    const lastBucketRef = useRef<number | null>(null);
    const extendRef = useRef<HTMLButtonElement>(null);
    useEffect(() => {
    const timer = setInterval(() => setRemaining((value) => Math.max(0, value - 1)), 1000);
    return () => clearInterval(timer);
    }, []);
    // Announce when the remaining time enters a new threshold: 120, 60 or 30 s.
    useEffect(() => {
    if (remaining <= 0) return;
    const bucket = [...ANNOUNCE_AT].reverse().find((limit) => remaining <= limit) ?? null;
    if (bucket !== null && bucket !== lastBucketRef.current) {
    lastBucketRef.current = bucket;
    setAnnouncement(t.announce(formatRemaining(remaining, lang)));
    }
    }, [remaining, lang, t]);
    const extend = () => {
    setRemaining(SESSION_SECONDS);
    lastBucketRef.current = null;
    setAnnouncement('');
    toast.show({ title: t.extended, tone: 'success' });
    };
    const expired = remaining <= 0;
    const warning = !expired && !signedOut && remaining <= WARN_AT;
    return (
    <Stack gap="md">
    {/* A blocking dialog: it interrupts, ignores Escape and outside presses, and asks for one of two actions. */}
    <Dialog
    open={warning}
    dismissible={false}
    size="sm"
    title={t.heading}
    description={t.body}
    initialFocus={extendRef}
    footer={
    <>
    <Button variant="secondary" onClick={() => setSignedOut(true)}>
    {t.signOut}
    </Button>
    <Button ref={extendRef} onClick={extend}>
    {t.extend}
    </Button>
    </>
    }
    >
    <div data-part="session-warning">
    <ProgressBar
    label={t.remaining}
    value={remaining}
    max={WARN_AT}
    showValue={false}
    valueText={humanRemaining(remaining, lang)}
    description={formatRemaining(remaining, lang)}
    tone={remaining <= 30 ? 'danger' : 'brand'}
    />
    {/* The visible countdown is not a live region; this one speaks at 120, 60 and 30 s only. */}
    <div role="status" aria-live="polite" className="sr-only">
    {announcement}
    </div>
    </div>
    </Dialog>
    {!warning && !expired && !signedOut ? <Text tone="subtle">{t.active}</Text> : null}
    {expired && !signedOut ? <ResultState tone="warning" headingLevel={3} focusOnMount title={t.expiredTitle} description={t.expired} /> : null}
    {signedOut ? <Text>{t.signedOut}</Text> : null}
    </Stack>
    );
    }
    /**
    * A blocking dialog that appears 120 s before the session ends, with the remaining time in a progress bar.
    * It announces at 120, 60 and 30 s only; "Stay signed in" resets the full session and shows a toast.
    */
    export function SessionCountdown({ lang = 'tr', startAt = 125 }: { lang?: Lang; startAt?: number }) {
    return (
    <LangProvider lang={lang}>
    <div className="w-full max-w-md">
    <SessionCountdownDemo lang={lang} startAt={startAt} />
    </div>
    </LangProvider>
    );
    }

    The second example starts at 31 seconds; the last threshold arrives after 1 second.

    Live example, StorybookOpen in Storybook

    In the code input, paste “123 456”, enter a wrong code and watch the 30-second cooldown. The sample code is 123456.

    Live example, StorybookOpen in Storybook

    Enter the 6-digit code sent to your phone

    Sample code: 123456
    import { Button, InlineAlert, Stack, Text, TextInput } from '@bigbang/ui';
    import { useEffect, useState, type ClipboardEvent, type FormEvent } from 'react';
    import { type Lang, LangProvider } from '../lang';
    const copy = {
    tr: {
    label: 'Doğrulama kodu',
    description: 'Telefonunuza gönderilen 6 haneli kodu girin',
    short: 'Kod 6 haneli olmalı',
    wrong: (left: number) => `Kod hatalı. ${left} deneme hakkınız kaldı.`,
    noAttempts: 'Deneme hakkınız bitti. Yeni kod isteyin.',
    expired: 'Kodun süresi doldu. Yeni kod isteyin.',
    verify: 'Doğrula',
    resend: 'Kodu tekrar gönder',
    resendWait: (seconds: number) => `Kodu tekrar gönder (${seconds} sn)`,
    resent: 'Yeni kod gönderildi',
    verified: 'Kod doğrulandı',
    hint: 'Deneme kodu: 123456',
    },
    en: {
    label: 'Verification code',
    description: 'Enter the 6-digit code sent to your phone',
    short: 'The code must be 6 digits',
    wrong: (left: number) => `Wrong code. You have ${left} ${left === 1 ? 'attempt' : 'attempts'} left.`,
    noAttempts: 'You have no attempts left. Request a new code.',
    expired: 'The code has expired. Request a new code.',
    verify: 'Verify',
    resend: 'Resend code',
    resendWait: (seconds: number) => `Resend code (${seconds} s)`,
    resent: 'A new code was sent',
    verified: 'Code verified',
    hint: 'Sample code: 123456',
    },
    };
    export const CODE_LENGTH = 6;
    export const RESEND_COOLDOWN = 30;
    export const CODE_LIFETIME = 180;
    export const MAX_ATTEMPTS = 3;
    /** Keep digits only, so a pasted "123 456" or "123-456" becomes "123456". */
    export const digitsOnly = (value: string) => value.replace(/\D/g, '').slice(0, CODE_LENGTH);
    /**
    * One single-line input for a one-time code. It strips non-digits (also from pasted text),
    * counts wrong attempts, expires after 180 s and allows a resend after a 30 s cooldown.
    */
    export function OneTimeCode({ lang = 'tr', correctCode = '123456' }: { lang?: Lang; correctCode?: string }) {
    const t = copy[lang];
    const [value, setValue] = useState('');
    const [error, setError] = useState('');
    const [attempts, setAttempts] = useState(MAX_ATTEMPTS);
    const [cooldown, setCooldown] = useState(RESEND_COOLDOWN);
    const [lifetime, setLifetime] = useState(CODE_LIFETIME);
    const [notice, setNotice] = useState<'resent' | 'verified' | null>(null);
    const [done, setDone] = useState(false);
    useEffect(() => {
    const timer = setInterval(() => {
    setCooldown((seconds) => Math.max(0, seconds - 1));
    setLifetime((seconds) => Math.max(0, seconds - 1));
    }, 1000);
    return () => clearInterval(timer);
    }, []);
    const expired = lifetime <= 0;
    const shownError = done ? '' : expired ? t.expired : error;
    const onPaste = (event: ClipboardEvent<HTMLInputElement>) => {
    // maxLength would cut "123 456" at 6 characters, spaces included; clean the text first.
    event.preventDefault();
    setValue(digitsOnly(event.clipboardData.getData('text')));
    setError('');
    };
    const onSubmit = (event: FormEvent) => {
    event.preventDefault();
    if (expired) return;
    setNotice(null);
    if (value.length !== CODE_LENGTH) {
    setError(t.short);
    } else if (attempts <= 0) {
    setError(t.noAttempts);
    } else if (value !== correctCode) {
    const left = attempts - 1;
    setAttempts(left);
    setError(left > 0 ? t.wrong(left) : t.noAttempts);
    } else {
    setError('');
    setDone(true);
    setNotice('verified');
    }
    };
    const resend = () => {
    setValue('');
    setError('');
    setAttempts(MAX_ATTEMPTS);
    setCooldown(RESEND_COOLDOWN);
    setLifetime(CODE_LIFETIME);
    setNotice('resent');
    };
    return (
    <LangProvider lang={lang}>
    <form noValidate onSubmit={onSubmit} className="w-full max-w-md">
    <Stack gap="lg">
    <TextInput
    label={t.label}
    description={t.description}
    name="one-time-code"
    inputMode="numeric"
    autoComplete="one-time-code"
    maxLength={CODE_LENGTH}
    pattern="[0-9]*"
    value={value}
    onValueChange={(next) => {
    setValue(digitsOnly(next));
    setError('');
    setNotice(null);
    }}
    onPaste={onPaste}
    invalid={Boolean(shownError)}
    />
    {/* The message sits under the field in an InlineAlert; `invalid` marks the input. It is announced when it appears. */}
    {shownError ? (
    <InlineAlert tone="danger" announce>
    {shownError}
    </InlineAlert>
    ) : null}
    {notice ? (
    <InlineAlert tone={notice === 'verified' ? 'success' : 'info'} announce>
    {notice === 'verified' ? t.verified : t.resent}
    </InlineAlert>
    ) : null}
    <div className="flex flex-wrap gap-sm">
    <Button type="submit" disabled={done}>
    {t.verify}
    </Button>
    <Button variant="tertiary" disabled={cooldown > 0} focusableWhenDisabled onClick={resend}>
    {cooldown > 0 ? t.resendWait(cooldown) : t.resend}
    </Button>
    </div>
    <Text size="sm" tone="subtle">
    {t.hint}
    </Text>
    </Stack>
    </form>
    </LangProvider>
    );
    }
    Rule Value
    Warning time 120 s before the end. The user gets at least 20 s to respond (WCAG 2.2.1).
    Warning surface A Dialog that cannot be dismissed (dismissible={false}): Escape and pressing outside do not close it.
    Extending With one simple action: primary “Stay signed in”. Secondary “Sign out”. Focus starts on “Stay signed in”.
    Time display A ProgressBar shows the time left; the visible text is “1 min 40 s”, the spoken value is valueText: “1 minute 40 seconds”. The countdown is not a live region.
    Announcements Only at 120, 60 and 30 s; never every second.
    After the timeout A ResultState says “The session has timed out”; focus moves to its heading. Keep input that was not sent where possible.
    Code field One TextInput: inputMode="numeric", autoComplete="one-time-code", maxLength={6}, pattern="[0-9]*".
    Errors and notices An InlineAlert under the field: wrong code is danger, resend is info, verified is success; each is announced when it appears.
    Pasting Spaces and letters are stripped: “123 456” becomes “123456”.
    Resend 30 s cooldown; meanwhile a focusableWhenDisabled button reads “Resend code (30 s)”.
    Code lifetime 180 s. A clear message shows when it expires.
    Attempts 3. A wrong code states the attempts left.
    DoThe button label says the action.
    Don'tA generic label does not say the result.
    Wrong code. You have 2 attempts left.
    DoThe error states the attempts left.
    Invalid value.
    Don'tA generic error makes users guess.
    1. 120 s before the session ends, a dialog that cannot be dismissed opens: heading, the time left in a ProgressBar, “Sign out” and “Stay signed in”. Focus moves to “Stay signed in”.
    2. The dialog updates the remaining time every second. Escape and pressing outside do not close it; the user picks one of the two actions.
    3. “Stay signed in” resets the time to the full session (15 minutes), the dialog closes and a “Session extended by 15 minutes” Toast appears.
    4. “Sign out” ends the session. If the time runs out, the dialog closes and a ResultState says “The session has timed out”; keep input that was not sent where possible.
    5. In the code input, the user types or pastes the code and checks it with “Verify”. An incomplete code shows “The code must be 6 digits”.
    6. A wrong code uses up an attempt and the error is announced in an InlineAlert. When the cooldown ends, “Resend code” becomes available; a new code resets the lifetime and the attempts and shows “A new code was sent”.
    • The warning is a modal Dialog: focus stays inside and the page behind is inert. It cannot be dismissed because the user must respond.
    • The ProgressBar reads the time left through valueText: “1 minute 30 seconds”. The visible countdown is not a live region; a separate role="status" region speaks only at 120, 60 and 30 s.
    • Extending takes one button and focus is on it, so the user can respond within 20 s (WCAG 2.2.1).
    • When the time runs out the dialog closes and focus moves to the ResultState heading, so the user does not lose their place.
    • The code field is a single input; password managers and SMS autofill work through autoComplete="one-time-code".
    • The resend button takes focus during the cooldown and has aria-disabled="true"; the remaining time is in its text.
    • A wrong code marks the field with aria-invalid; the message is read in the InlineAlert under the field (“Error: …”) and announced when it appears.
    • The heading states the situation: “Your session is about to end”.
    • Buttons say the action: “Stay signed in”, “Sign out”.
    • Time reads “1 min 40 s”; do not use decimals or a clock format.
    • Announcement text: “Your session ends in 1 min 0 s”.
    • Code description: “Enter the 6-digit code sent to your phone”.
    • Errors: “The code must be 6 digits”, “Wrong code. You have 2 attempts left.”, “The code has expired. Request a new code.”
    Item Motion Preset
    Warning dialog The component’s own open and close motion built in
    Time bar The fill shrinks with the component’s own motion built in
    Toast, InlineAlert The component’s own enter motion built in
    Buttons 0.97 scale on press motion.press

    With reduced motion, movement is removed and only the fade stays.

    Dialog, ProgressBar, ResultState, Toast, InlineAlert, Button, Stack, Text, TextInput. Related pattern: Forms and validation.

    Upcoming component: OtpInput draws segmented boxes over the same single input.