Error recovery
When something goes wrong, the form takes the blame, keeps every entry and shows the way out. Problems listed above the form, checks while typing with a fix for typos, a failed send that retries by itself, and an expired session that does not lose a word.
One of 3 components in Form states
Variants
Details
What Error recovery brings along, read from the registry and the source.
- Category
- Form states
- Area
- Content
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/error-recovery.tsx
- npm packages
- None
- Languages
- English and German, 44 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ErrorRecoveryProps interface from components/ui/error-recovery.tsx, with the comments from the code.
variantoptionalErrorRecoveryVariantsummary-topinline-livesubmit-failedsession-expired
eyebrowoptionalReact.ReactNodetitleoptionalReact.ReactNodedescriptionoptionalReact.ReactNodeshowErrorsoptionalbooleanStart with the errors shown, for previews of the error state
valuesoptional{ name?: string; email?: string; phone?: string; message?: string; username?: string }Start values of the fields
takenNamesoptionalstring[]inline-live: names that are taken
domainsoptionalstring[]inline-live: domains people often mistype, the right one first
fallbackEmailoptionalstringsubmit-failed: another way to reach you
accountoptionalstringsession-expired: the account that signs back in
submitLabeloptionalstringonSubmitoptional(data: Record<string, string>) => boolean | void | Promise<boolean | void>Your sender. Return false or throw to show the failure state.
linksoptional{ reset?: string }session-expired: where "Reset password" goes, #reset by default
Built-in texts
Error recovery writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 44 texts
| Key | English | German |
|---|---|---|
| name | English Name | German Name |
| English Email | German E-Mail | |
| phone | English Phone | German Telefon |
| message | English Message | German Nachricht |
| username | English Username | German Benutzername |
| password | English Password | German Passwort |
| nameNeeded | English Please add your name, so we know who we answer. | German Bitte gib deinen Namen an, damit wir wissen, wem wir antworten. |
| emailNeeded | English This address is missing the part after the @. Please use a form like name@example.com. | German Dieser Adresse fehlt der Teil nach dem @. Bitte nutze die Form name@beispiel.de. |
| phoneNeeded | English This number has too few digits. Please add the area code, so we can call back. | German Diese Nummer hat zu wenige Ziffern. Bitte ergänze die Vorwahl, damit wir zurückrufen können. |
| messageNeeded | English Please write a few words, so we know what it is about. | German Bitte schreib ein paar Worte, damit wir wissen, worum es geht. |
| fixedAll | English All fixed, you can send now | German Alles behoben, du kannst jetzt senden |
| left | English {n} fields still to fix | German Noch {n} Felder offen |
| leftOne | English One field still to fix | German Noch ein Feld offen |
| send | English Send | German Senden |
| sent | English Sent. Thank you. | German Gesendet. Danke. |
| checking | English Checking {u} | German Prüfe {u} |
| taken | English {u} is taken. How about {s}? | German {u} ist vergeben. Wie wäre es mit {s}? |
| free | English {u} is free | German {u} ist frei |
| didYouMean | English Did you mean {e}? | German Meintest du {e}? |
| useIt | English Use {e} | German {e} übernehmen |
| rules | English Your password needs | German Dein Passwort braucht |
| rule8 | English At least 10 characters | German Mindestens 10 Zeichen |
| ruleNum | English A number | German Eine Ziffer |
| ruleCase | English Upper and lower case letters | German Groß- und Kleinbuchstaben |
| ruleName | English Not your username | German Nicht deinen Benutzernamen |
| create | English Create account | German Konto anlegen |
| created | English Account created | German Konto angelegt |
| failTitle | English We could not send it, but nothing is lost | German Wir konnten es nicht senden, aber nichts ist verloren |
| failText | English Our server did not answer. Everything you entered is still here. | German Unser Server hat nicht geantwortet. Alles, was du eingegeben hast, ist noch da. |
| retryIn | English Trying again in {n} seconds | German Neuer Versuch in {n} Sekunden |
| retrying | English Trying again | German Neuer Versuch läuft |
| retryNow | English Try again now | German Jetzt erneut versuchen |
| copy | English Copy my message | German Nachricht kopieren |
| copied | English Copied to the clipboard | German In die Zwischenablage kopiert |
| other | English Or write to {e} | German Oder schreib an {e} |
| expiredTitle | English Your session ran out, your form did not | German Deine Sitzung ist abgelaufen, dein Formular nicht |
| expiredText | English For your safety we sign you out after 30 minutes. Enter your password and continue where you left off. | German Zu deiner Sicherheit melden wir dich nach 30 Minuten ab. Gib dein Passwort ein und mach dort weiter, wo du warst. |
| continue | English Sign in and continue | German Anmelden und weitermachen |
| pwNeeded | English Please enter your password to continue. | German Bitte gib dein Passwort ein, um weiterzumachen. |
| pwWrong | English This password does not match. Check caps lock, or reset it, your form waits. | German Dieses Passwort passt nicht. Prüf die Feststelltaste oder setz es zurück, dein Formular wartet. |
| reset | English Reset password | German Passwort zurücksetzen |
| kept | English Kept for you | German Für dich aufbewahrt |
| signedIn | English Welcome back, you can send now. | German Willkommen zurück, du kannst jetzt senden. |
| signedAs | English Signed in as {a} | German Angemeldet als {a} |
Accessibility
Read from the source of the component.
- Sets aria-describedby, aria-labelledby, aria-live and aria-modal, so screen readers announce purpose and state.
- Uses the roles alert, dialog and status.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 622 lines
'use client';
import { useEffect, useId, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { Input, PasswordInput } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Reveal } from '@/components/ui/reveal';
import { Section } from '@/components/ui/section';
import { DoneMark, ErrorSummary, FlowHead, isEmail, jumpTo, Unfold, useFocusFirstError, type FlowError } from '@/components/ui/form-flow';
/*
* When something goes wrong in a form, the form takes the blame, keeps every
* entry and shows the way out. Four moments, each a section to drop into a
* page or to copy the pattern from:
*
* summary-top all problems above the form, each a link, the box counts down as they are fixed
* inline-live checks after leaving a field, rules that tick, a typo in the address offered as a fix
* submit-failed the send did not go through, nothing is lost, retry runs by itself, other ways stay open
* session-expired the sign-in ran out mid form, a password brings it back without losing a word
*/
export type ErrorRecoveryVariant = 'summary-top' | 'inline-live' | 'submit-failed' | 'session-expired';
export interface ErrorRecoveryProps {
variant?: ErrorRecoveryVariant;
eyebrow?: React.ReactNode;
title?: React.ReactNode;
description?: React.ReactNode;
/** Start with the errors shown, for previews of the error state */
showErrors?: boolean;
/** Start values of the fields */
values?: { name?: string; email?: string; phone?: string; message?: string; username?: string };
/** inline-live: names that are taken */
takenNames?: string[];
/** inline-live: domains people often mistype, the right one first */
domains?: string[];
/** submit-failed: another way to reach you */
fallbackEmail?: string;
/** session-expired: the account that signs back in */
account?: string;
submitLabel?: string;
/** Your sender. Return false or throw to show the failure state. */
onSubmit?: (data: Record<string, string>) => boolean | void | Promise<boolean | void>;
/** session-expired: where "Reset password" goes, #reset by default */
links?: { reset?: string };
labels?: Partial<Texts>;
className?: string;
}
const TEXTS = {
en: {
name: 'Name',
email: 'Email',
phone: 'Phone',
message: 'Message',
username: 'Username',
password: 'Password',
nameNeeded: 'Please add your name, so we know who we answer.',
emailNeeded: 'This address is missing the part after the @. Please use a form like name@example.com.',
phoneNeeded: 'This number has too few digits. Please add the area code, so we can call back.',
messageNeeded: 'Please write a few words, so we know what it is about.',
fixedAll: 'All fixed, you can send now',
left: '{n} fields still to fix',
leftOne: 'One field still to fix',
send: 'Send',
sent: 'Sent. Thank you.',
checking: 'Checking {u}',
taken: '{u} is taken. How about {s}?',
free: '{u} is free',
didYouMean: 'Did you mean {e}?',
useIt: 'Use {e}',
rules: 'Your password needs',
rule8: 'At least 10 characters',
ruleNum: 'A number',
ruleCase: 'Upper and lower case letters',
ruleName: 'Not your username',
create: 'Create account',
created: 'Account created',
failTitle: 'We could not send it, but nothing is lost',
failText: 'Our server did not answer. Everything you entered is still here.',
retryIn: 'Trying again in {n} seconds',
retrying: 'Trying again',
retryNow: 'Try again now',
copy: 'Copy my message',
copied: 'Copied to the clipboard',
other: 'Or write to {e}',
expiredTitle: 'Your session ran out, your form did not',
expiredText: 'For your safety we sign you out after 30 minutes. Enter your password and continue where you left off.',
continue: 'Sign in and continue',
pwNeeded: 'Please enter your password to continue.',
pwWrong: 'This password does not match. Check caps lock, or reset it, your form waits.',
reset: 'Reset password',
kept: 'Kept for you',
signedIn: 'Welcome back, you can send now.',
signedAs: 'Signed in as {a}',
},
de: {
name: 'Name',
email: 'E-Mail',
phone: 'Telefon',
message: 'Nachricht',
username: 'Benutzername',
password: 'Passwort',
nameNeeded: 'Bitte gib deinen Namen an, damit wir wissen, wem wir antworten.',
emailNeeded: 'Dieser Adresse fehlt der Teil nach dem @. Bitte nutze die Form name@beispiel.de.',
phoneNeeded: 'Diese Nummer hat zu wenige Ziffern. Bitte ergänze die Vorwahl, damit wir zurückrufen können.',
messageNeeded: 'Bitte schreib ein paar Worte, damit wir wissen, worum es geht.',
fixedAll: 'Alles behoben, du kannst jetzt senden',
left: 'Noch {n} Felder offen',
leftOne: 'Noch ein Feld offen',
send: 'Senden',
sent: 'Gesendet. Danke.',
checking: 'Prüfe {u}',
taken: '{u} ist vergeben. Wie wäre es mit {s}?',
free: '{u} ist frei',
didYouMean: 'Meintest du {e}?',
useIt: '{e} übernehmen',
rules: 'Dein Passwort braucht',
rule8: 'Mindestens 10 Zeichen',
ruleNum: 'Eine Ziffer',
ruleCase: 'Groß- und Kleinbuchstaben',
ruleName: 'Nicht deinen Benutzernamen',
create: 'Konto anlegen',
created: 'Konto angelegt',
failTitle: 'Wir konnten es nicht senden, aber nichts ist verloren',
failText: 'Unser Server hat nicht geantwortet. Alles, was du eingegeben hast, ist noch da.',
retryIn: 'Neuer Versuch in {n} Sekunden',
retrying: 'Neuer Versuch läuft',
retryNow: 'Jetzt erneut versuchen',
copy: 'Nachricht kopieren',
copied: 'In die Zwischenablage kopiert',
other: 'Oder schreib an {e}',
expiredTitle: 'Deine Sitzung ist abgelaufen, dein Formular nicht',
expiredText: 'Zu deiner Sicherheit melden wir dich nach 30 Minuten ab. Gib dein Passwort ein und mach dort weiter, wo du warst.',
continue: 'Anmelden und weitermachen',
pwNeeded: 'Bitte gib dein Passwort ein, um weiterzumachen.',
pwWrong: 'Dieses Passwort passt nicht. Prüf die Feststelltaste oder setz es zurück, dein Formular wartet.',
reset: 'Passwort zurücksetzen',
kept: 'Für dich aufbewahrt',
signedIn: 'Willkommen zurück, du kannst jetzt senden.',
signedAs: 'Angemeldet als {a}',
},
};
type Texts = (typeof TEXTS)['en'];
const digits = (v: string) => v.replace(/\D/g, '').length;
/* ------------------------------------------------------------------ */
/* summary-top */
/* ------------------------------------------------------------------ */
function SummaryTop({ p, t }: { p: ErrorRecoveryProps; t: Texts }) {
const id = useId();
const [v, setV] = useState({ name: p.values?.name ?? '', email: p.values?.email ?? '', phone: p.values?.phone ?? '', message: p.values?.message ?? '' });
const check = (x: typeof v) => {
const e: Record<string, string> = {};
if (!x.name.trim()) e.name = t.nameNeeded;
if (!isEmail(x.email)) e.email = t.emailNeeded;
if (x.phone && digits(x.phone) < 6) e.phone = t.phoneNeeded;
if (!x.message.trim()) e.message = t.messageNeeded;
return e;
};
const [errors, setErrors] = useState<Record<string, string>>(p.showErrors ? check(v) : {});
const [tried, setTried] = useState(Boolean(p.showErrors));
const [sent, setSent] = useState(false);
const summaryRef = useRef<HTMLDivElement>(null);
const set = (k: keyof typeof v) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const next = { ...v, [k]: e.target.value };
setV(next);
// After the first try, fixing a field takes it off the list right away
if (tried) setErrors(check(next));
};
const labels: Record<string, string> = { name: t.name, email: t.email, phone: t.phone, message: t.message };
const summary: FlowError[] = Object.keys(errors).map((k) => ({ field: `${id}-${k}`, message: labels[k] }));
const allFixed = tried && summary.length === 0 && !sent;
return (
<Reveal effect="rise" className="mx-auto max-w-[40rem]">
<form
noValidate
className="kit-card grid gap-5 p-6 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
const found = check(v);
setErrors(found);
setTried(true);
if (Object.keys(found).length) {
requestAnimationFrame(() => summaryRef.current?.focus());
return;
}
p.onSubmit?.(v);
setSent(true);
}}
>
<ErrorSummary ref={summaryRef} errors={summary} />
{allFixed && (
<p role="status" className="kit-rise flex items-center gap-2.5 rounded-xl border border-[color-mix(in_oklab,var(--success)_35%,var(--border))] bg-[color-mix(in_oklab,var(--success)_7%,var(--card))] p-4 text-[14.5px] font-medium text-success-foreground">
<Icon name="check-circle" size={18} />
{t.fixedAll}
</p>
)}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<Input id={`${id}-name`} label={t.name} required autoComplete="name" value={v.name} onChange={set('name')} error={errors.name} success={tried && !errors.name && Boolean(v.name)} />
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={v.email} onChange={set('email')} error={errors.email} success={tried && !errors.email} />
<Input id={`${id}-phone`} type="tel" label={t.phone} optional autoComplete="tel" value={v.phone} onChange={set('phone')} error={errors.phone} className="sm:col-span-2" />
<Textarea id={`${id}-message`} label={t.message} required value={v.message} onChange={set('message')} error={errors.message} minRows={3} className="sm:col-span-2" />
</div>
<div className="flex flex-col gap-3 border-t border-border pt-5 sm:flex-row sm:items-center sm:justify-between">
<p aria-live="polite" className="text-[13px] tabular-nums text-muted-foreground">
{sent ? <span className="text-success-foreground">{t.sent}</span> : summary.length === 1 ? t.leftOne : summary.length ? t.left.replace('{n}', String(summary.length)) : ''}
</p>
<Button type="submit" icon="send" className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* inline-live */
/* ------------------------------------------------------------------ */
/** Closest known domain by edit distance, for "did you mean" */
function closest(domain: string, list: string[]) {
const dist = (a: string, b: string) => {
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
for (let j = 1; j <= b.length; j++) dp[0][j] = j;
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
return dp[a.length][b.length];
};
let best: string | null = null;
let score = 3;
for (const d of list) {
const s = dist(domain, d);
if (s > 0 && s < score) {
score = s;
best = d;
}
}
return best;
}
function InlineLive({ p, t }: { p: ErrorRecoveryProps; t: Texts }) {
const id = useId();
const [username, setUsername] = useState(p.values?.username ?? '');
const [email, setEmail] = useState(p.values?.email ?? '');
const [pw, setPw] = useState('');
const [touched, setTouched] = useState<Record<string, boolean>>({ username: Boolean(p.values?.username), email: Boolean(p.values?.email) });
const [checkingName, setCheckingName] = useState(false);
const [nameState, setNameState] = useState<'free' | 'taken' | null>(null);
const [done, setDone] = useState(false);
const taken = (p.takenNames ?? []).map((x) => x.toLowerCase());
// A short pause, as if a server answered, then free or taken
useEffect(() => {
if (!touched.username || username.trim().length < 3) {
setNameState(null);
return;
}
setCheckingName(true);
const timer = window.setTimeout(() => {
setCheckingName(false);
setNameState(taken.includes(username.trim().toLowerCase()) ? 'taken' : 'free');
}, 650);
return () => window.clearTimeout(timer);
}, [username, touched.username]); // eslint-disable-line react-hooks/exhaustive-deps
const suggestion = `${username.trim().toLowerCase()}2`;
const at = email.lastIndexOf('@');
const domain = at > 0 ? email.slice(at + 1).toLowerCase() : '';
const fix = domain && p.domains ? closest(domain, p.domains) : null;
const fixed = fix ? `${email.slice(0, at + 1)}${fix}` : '';
const emailError = touched.email && email && !isEmail(email) ? t.emailNeeded : undefined;
const rules = [
{ ok: pw.length >= 10, label: t.rule8 },
{ ok: /\d/.test(pw), label: t.ruleNum },
{ ok: /[a-z]/.test(pw) && /[A-Z]/.test(pw), label: t.ruleCase },
{ ok: pw.length > 0 && !(username && pw.toLowerCase().includes(username.toLowerCase())), label: t.ruleName },
];
const ready = nameState === 'free' && isEmail(email) && rules.every((r) => r.ok);
if (done)
return (
<div className="kit-card mx-auto flex max-w-[34rem] flex-col items-center p-8 text-center" role="status">
<DoneMark size={56} />
<p className="mt-4 text-[19px] font-semibold text-foreground">{t.created}</p>
</div>
);
return (
<Reveal effect="rise" className="mx-auto max-w-[34rem]">
<form
noValidate
className="kit-card grid gap-5 p-6 sm:p-8"
onSubmit={(e) => {
e.preventDefault();
setTouched({ username: true, email: true, pw: true });
if (!ready) {
const first = nameState !== 'free' ? `${id}-user` : !isEmail(email) ? `${id}-email` : `${id}-pw`;
jumpTo(first);
return;
}
p.onSubmit?.({ username, email });
setDone(true);
}}
>
<div>
<Input
id={`${id}-user`}
label={t.username}
required
autoComplete="username"
prefix="@"
value={username}
onChange={(e) => setUsername(e.target.value.replace(/\s/g, ''))}
onBlur={() => setTouched((x) => ({ ...x, username: true }))}
success={nameState === 'free'}
invalid={nameState === 'taken'}
aria-describedby={`${id}-user-msg`}
trailing={checkingName ? <Icon name="loader" size={16} className="me-2 animate-spin text-muted-foreground motion-reduce:animate-none" /> : undefined}
/>
<p id={`${id}-user-msg`} aria-live="polite" className="mt-2 min-h-5 text-[13px]">
{checkingName ? (
<span className="text-muted-foreground">{t.checking.replace('{u}', `@${username}`)}</span>
) : nameState === 'taken' ? (
<span className="kit-rise flex flex-wrap items-center gap-x-2 text-destructive">
{t.taken.replace('{u}', `@${username}`).replace('{s}', `@${suggestion}`)}
<button type="button" onClick={() => setUsername(suggestion)} className="min-h-9 font-medium text-foreground underline underline-offset-4 max-sm:min-h-11 focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none">
{t.useIt.replace('{e}', `@${suggestion}`)}
</button>
</span>
) : nameState === 'free' ? (
<span className="text-success-foreground">{t.free.replace('{u}', `@${username}`)}</span>
) : null}
</p>
</div>
<div>
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} onBlur={() => setTouched((x) => ({ ...x, email: true }))} error={emailError} success={touched.email && isEmail(email) && !fix} />
{/* A likely typo in the domain, offered as a one tap fix */}
<Unfold open={Boolean(fix) && touched.email}>
<p className="mt-2 flex flex-wrap items-center gap-x-2 rounded-lg bg-surface px-3 py-2 text-[13px] text-muted-foreground">
<Icon name="sparkle" size={14} className="text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]" />
{t.didYouMean.replace('{e}', fixed)}
<button type="button" onClick={() => setEmail(fixed)} className="min-h-9 font-medium text-foreground underline underline-offset-4 max-sm:min-h-11 focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none">
{t.useIt.replace('{e}', fix ?? '')}
</button>
</p>
</Unfold>
</div>
<div>
<PasswordInput id={`${id}-pw`} label={t.password} required autoComplete="new-password" value={pw} onChange={(e) => setPw(e.target.value)} aria-describedby={`${id}-rules`} invalid={Boolean(touched.pw) && !rules.every((r) => r.ok)} />
{/* Rules tick off one by one as the password grows */}
<div id={`${id}-rules`} className="mt-3">
<p className="text-[12.5px] text-muted-foreground">{t.rules}</p>
<ul className="mt-2 grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{rules.map((r) => (
<li key={r.label} className={cn('flex items-center gap-2 text-[13px] transition-colors duration-300', r.ok ? 'text-foreground' : 'text-muted-foreground')}>
<span className={cn('grid size-4 shrink-0 place-items-center rounded-full transition-[background-color,transform] duration-300 ease-[cubic-bezier(0.34,1.7,0.64,1)]', r.ok ? 'scale-110 bg-success text-background' : 'border border-border-strong')}>{r.ok && <Icon name="check" size={10} strokeWidth={3} />}</span>
{r.label}
<span className="sr-only">{r.ok ? '✓' : ''}</span>
</li>
))}
</ul>
</div>
</div>
<Button type="submit" icon="arrow-right" className="mt-1 w-full">
{p.submitLabel ?? t.create}
</Button>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* submit-failed */
/* ------------------------------------------------------------------ */
function SubmitFailed({ p, t }: { p: ErrorRecoveryProps; t: Texts }) {
const id = useId();
const [v, setV] = useState({ name: p.values?.name ?? '', email: p.values?.email ?? '', message: p.values?.message ?? '' });
const [failed, setFailed] = useState(Boolean(p.showErrors));
const [count, setCount] = useState(15);
const [busy, setBusy] = useState(false);
const [sent, setSent] = useState(false);
const sentRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
if (sent) sentRef.current?.focus({ preventScroll: true });
}, [sent]);
const [copied, setCopied] = useState(false);
const attempts = useRef(0);
const banner = useRef<HTMLDivElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [attempt, setAttempt] = useState(0);
useFocusFirstError(formRef, attempt);
const check = (x: typeof v) => {
const e: Record<string, string> = {};
if (!x.name.trim()) e.name = t.nameNeeded;
if (!isEmail(x.email)) e.email = t.emailNeeded;
if (!x.message.trim()) e.message = t.messageNeeded;
return e;
};
const change = (k: keyof typeof v, value: string) => {
const next = { ...v, [k]: value };
setV(next);
if (errors[k]) setErrors(check(next));
};
const send = async () => {
setBusy(true);
attempts.current += 1;
let ok: boolean | void = undefined;
try {
ok = await p.onSubmit?.(v);
} catch {
ok = false;
}
// Without a sender of your own the first attempt shows the failure, so the state can be seen
if (!p.onSubmit) ok = attempts.current > 1 || p.showErrors ? true : false;
window.setTimeout(() => {
setBusy(false);
if (ok === false) {
setFailed(true);
setCount(15);
requestAnimationFrame(() => banner.current?.focus());
} else {
setFailed(false);
setSent(true);
}
}, 700);
};
// While failed, a gentle countdown retries by itself
useEffect(() => {
if (!failed || busy || sent) return;
if (count <= 0) {
send();
return;
}
const timer = window.setTimeout(() => setCount((c) => c - 1), 1000);
return () => window.clearTimeout(timer);
}, [failed, count, busy, sent]); // eslint-disable-line react-hooks/exhaustive-deps
if (sent)
return (
<div className="kit-card mx-auto flex max-w-[40rem] flex-col items-center p-8 text-center" role="status">
<DoneMark size={56} />
<p ref={sentRef} tabIndex={-1} className="mt-4 text-[19px] font-semibold text-foreground outline-none">{t.sent}</p>
</div>
);
return (
<Reveal effect="rise" className="mx-auto max-w-[40rem]">
<form
noValidate
className="kit-card overflow-hidden"
ref={formRef}
onSubmit={(e) => {
e.preventDefault();
const found = check(v);
setErrors(found);
if (Object.keys(found).length) {
setAttempt((a) => a + 1);
return;
}
send();
}}
>
<Unfold open={failed}>
<div ref={banner} tabIndex={-1} role="alert" className="border-b border-[color-mix(in_oklab,var(--destructive)_35%,var(--border))] bg-[color-mix(in_oklab,var(--destructive)_6%,var(--card))] p-5 outline-none sm:p-6">
<p className="flex items-start gap-3 text-[15.5px] font-semibold text-foreground">
<Icon name="wifi-off" size={19} className="mt-0.5 shrink-0 text-destructive" />
{t.failTitle}
</p>
<p className="mt-1 ps-[1.9rem] text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{t.failText}</p>
<div className="mt-4 flex flex-col gap-3 ps-[1.9rem] sm:flex-row sm:items-center">
{/* The countdown as a draining bar inside the button */}
<Button type="button" variant="outline" size="sm" iconLeft="rotate-cw" onClick={send} disabled={busy} className="relative overflow-hidden">
<span aria-hidden className="absolute inset-y-0 start-0 bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] transition-[width] duration-1000 ease-linear" style={{ width: `${Math.round((count / 15) * 100)}%` }} />
<span className="relative">{t.retryNow}</span>
</Button>
<span aria-live="polite" className="text-[13px] tabular-nums text-muted-foreground">
{busy ? t.retrying : t.retryIn.replace('{n}', String(count))}
</span>
</div>
<div className="mt-4 flex flex-wrap gap-x-5 gap-y-2 ps-[1.9rem] text-[13px]">
<button
type="button"
onClick={() => {
navigator.clipboard?.writeText(`${v.name}\n${v.email}\n\n${v.message}`).catch(() => {});
setCopied(true);
}}
className="inline-flex min-h-9 items-center gap-1.5 font-medium text-foreground underline-offset-4 hover:underline max-sm:min-h-11 focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_45%,transparent)] focus-visible:outline-none"
>
<Icon name={copied ? 'check' : 'copy'} size={14} />
{copied ? t.copied : t.copy}
</button>
{p.fallbackEmail && (
<a href={`mailto:${p.fallbackEmail}`} className="inline-flex min-h-9 items-center gap-1.5 font-medium text-foreground underline-offset-4 hover:underline max-sm:min-h-11">
<Icon name="mail" size={14} />
{t.other.replace('{e}', p.fallbackEmail)}
</a>
)}
</div>
</div>
</Unfold>
<div className="grid gap-5 p-6 sm:p-8">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<Input id={`${id}-name`} label={t.name} required autoComplete="name" value={v.name} onChange={(e) => change('name', e.target.value)} error={errors.name} />
<Input id={`${id}-email`} type="email" label={t.email} required autoComplete="email" value={v.email} onChange={(e) => change('email', e.target.value)} error={errors.email} />
</div>
<Textarea id={`${id}-message`} label={t.message} required value={v.message} onChange={(e) => change('message', e.target.value)} error={errors.message} minRows={4} labelAction={failed ? <span className="inline-flex items-center gap-1 text-[12.5px] text-success-foreground"><Icon name="shield" size={13} />{t.kept}</span> : undefined} />
<div className="flex justify-end border-t border-border pt-5">
<Button type="submit" icon={busy ? undefined : 'send'} disabled={busy} className="max-sm:w-full">
{busy && <Icon name="loader" size={16} className="animate-spin motion-reduce:animate-none" />}
{busy ? t.retrying : (p.submitLabel ?? t.send)}
</Button>
</div>
</div>
</form>
</Reveal>
);
}
/* ------------------------------------------------------------------ */
/* session-expired */
/* ------------------------------------------------------------------ */
function SessionExpired({ p, t }: { p: ErrorRecoveryProps; t: Texts }) {
const id = useId();
const [expired, setExpired] = useState(p.showErrors !== false);
const [pw, setPw] = useState('');
const [error, setError] = useState('');
const [tries, setTries] = useState(0);
const [v, setV] = useState({ message: p.values?.message ?? '' });
const pwRef = useRef<HTMLInputElement>(null);
const done = useRef<HTMLParagraphElement>(null);
return (
<div className="relative mx-auto max-w-[44rem]">
{/* The form stays visible behind a calm veil, so people see their words are safe */}
<Reveal effect="rise">
<div aria-hidden={expired || undefined} inert={expired || undefined} className={cn('kit-card p-6 transition-[filter,opacity] duration-500 sm:p-8', expired && 'min-h-[38rem] opacity-60 blur-[1.5px] sm:min-h-[33rem]')}>
{!expired && (
<p ref={done} tabIndex={-1} role="status" className="kit-rise mb-5 flex items-center gap-2.5 text-[14px] font-medium text-success-foreground outline-none">
<Icon name="check-circle" size={17} />
{t.signedIn}
</p>
)}
<Textarea label={t.message} value={v.message} onChange={(e) => setV({ message: e.target.value })} minRows={7} labelAction={<span className="inline-flex items-center gap-1 text-[12.5px] text-muted-foreground"><Icon name="shield" size={13} />{t.kept}</span>} />
<div className="mt-5 flex justify-end">
<Button icon="send" className="max-sm:w-full">
{p.submitLabel ?? t.send}
</Button>
</div>
</div>
</Reveal>
{expired && (
<div className="absolute inset-0 z-10 grid place-items-center p-3 sm:p-6">
<form
role="dialog"
aria-modal="true"
aria-labelledby={`${id}-t`}
noValidate
className="kit-rise w-full max-w-[26rem] rounded-2xl border border-border bg-card p-6 shadow-[var(--shadow-lg)]"
onSubmit={(e) => {
e.preventDefault();
if (!pw) {
setError(t.pwNeeded);
pwRef.current?.focus();
return;
}
// Without a sender of your own the first password counts as wrong, so the message can be seen
if (!p.onSubmit && tries === 0) {
setTries(1);
setError(t.pwWrong);
pwRef.current?.focus();
return;
}
setExpired(false);
requestAnimationFrame(() => done.current?.focus());
}}
>
<span className="grid size-11 place-items-center rounded-xl bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]">
<Icon name="lock" size={20} />
</span>
<h3 id={`${id}-t`} className="mt-4 text-balance text-[18px] font-semibold leading-snug text-foreground">
{t.expiredTitle}
</h3>
<p className="mt-1.5 text-pretty text-[13.5px] leading-relaxed text-muted-foreground">{t.expiredText}</p>
{p.account && <p className="mt-4 rounded-lg bg-surface px-3 py-2 text-[13px] text-foreground">{t.signedAs.replace('{a}', p.account)}</p>}
<PasswordInput ref={pwRef} id={`${id}-pw`} label={t.password} autoComplete="current-password" value={pw} onChange={(e) => (setPw(e.target.value), setError(''))} error={error || undefined} className="mt-4" />
<Button type="submit" icon="arrow-right" className="mt-5 w-full">
{t.continue}
</Button>
<a href={p.links?.reset ?? '#reset'} className="mt-3 flex min-h-11 items-center justify-center text-[13px] font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
{t.reset}
</a>
</form>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Section */
/* ------------------------------------------------------------------ */
/**
* Forms that recover well.
* <ErrorRecovery variant="summary-top" showErrors values={{ email: 'lena@' }} />
* <ErrorRecovery variant="submit-failed" onSubmit={send} fallbackEmail="hello@example.com" />
*/
export function ErrorRecovery(props: ErrorRecoveryProps) {
const { variant = 'summary-top', eyebrow, title, description, labels, className } = props;
const t = useTexts(TEXTS, labels);
return (
<Section className={className}>
<FlowHead eyebrow={eyebrow} title={title} description={description} align="center" className="mb-10 sm:mb-14" />
{variant === 'summary-top' && <SummaryTop p={props} t={t} />}
{variant === 'inline-live' && <InlineLive p={props} t={t} />}
{variant === 'submit-failed' && <SubmitFailed p={props} t={t} />}
{variant === 'session-expired' && <SessionExpired p={props} t={t} />}
</Section>
);
}Frequent questions
How do I install Error recovery?
With aiw add @ai-website/error-recovery. The command puts components/ui/error-recovery.tsx into your project, the code is yours from then on. It brings along button, icon, reveal, section, motion, form-flow, input and textarea. It needs no further npm packages.
Which variants does Error recovery have?
4 variants, Summary on top, Checks while typing, Send failed and Session expired. You pick one with the variant prop, for example variant="inline-live".
Can I translate the texts of Error recovery?
Yes. Error recovery carries 44 texts of its own in English and German, such as “Name”, “Email” and “Phone”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Error recovery?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Error recovery with aiw_components and install it with aiw_add @ai-website/error-recovery. Name the variant in your prompt, for example variant="summary-top", and the assistant writes your content straight into the props.
Can I adjust Error recovery without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching ErrorRecovery code to copy.