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.
When to use
Section titled “When to use”- 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.
Live example
Section titled “Live example”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.
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.
In the code input, paste “123 456”, enter a wrong code and watch the 30-second cooldown. The sample code is 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. |
Behaviour
Section titled “Behaviour”- 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”. - The dialog updates the remaining time every second. Escape and pressing outside do not close it; the user picks one of the two actions.
- “Stay signed in” resets the time to the full session (15 minutes), the dialog closes and a “Session extended by 15 minutes” Toast appears.
- “Sign out” ends the session. If the time runs out, the dialog closes and a
ResultStatesays “The session has timed out”; keep input that was not sent where possible. - 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”.
- 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”.
Accessibility
Section titled “Accessibility”- 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
ProgressBarreads the time left throughvalueText: “1 minute 30 seconds”. The visible countdown is not a live region; a separaterole="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
ResultStateheading, 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 theInlineAlertunder the field (“Error: …”) and announced when it appears.
Content
Section titled “Content”- 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.”
Motion
Section titled “Motion”| 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.
Components used
Section titled “Components used”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.