Skip to content
UI Kit
Accent color

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

4 variants

Variants

Summary on top

All problems in a box above the form, each a link to its field. Fixing a field takes it off the list at once and draws a check, when the last one goes the box turns into a green all clear.

Open in Studio

Checks while typing

Checks after leaving a field, never while still typing. A taken username offers a free one, a mistyped domain is offered as a one tap fix, password rules tick off one by one.

Open in Studio

Send failed

The send did not go through and the form says so plainly, every word is still there. A countdown drains inside the retry button and tries again by itself, the message can be copied and another way to write stays open.

Open in Studio

Session expired

The sign-in ran out in the middle of a long text. The form stays visible behind a soft veil, a small dialog asks for the password, and after signing in the words are exactly where they were.

Open in Studio

Details

What Error recovery brings along, read from the registry and the source.

Category
Form states
Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/error-recovery.tsx
Brings along
utilskitbuttoniconrevealsectionmotionform-flowinputtextarea
npm packages
None
Languages
English and German, 44 texts of its own

Props

The ErrorRecoveryProps interface from components/ui/error-recovery.tsx, with the comments from the code.

  • variantoptional
    ErrorRecoveryVariant

    summary-topinline-livesubmit-failedsession-expired

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • showErrorsoptional
    boolean

    Start 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

  • takenNamesoptional
    string[]

    inline-live: names that are taken

  • domainsoptional
    string[]

    inline-live: domains people often mistype, the right one first

  • fallbackEmailoptional
    string

    submit-failed: another way to reach you

  • accountoptional
    string

    session-expired: the account that signs back in

  • submitLabeloptional
    string
  • onSubmitoptional
    (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
nameEnglish NameGerman Name
emailEnglish EmailGerman E-Mail
phoneEnglish PhoneGerman Telefon
messageEnglish MessageGerman Nachricht
usernameEnglish UsernameGerman Benutzername
passwordEnglish PasswordGerman Passwort
nameNeededEnglish Please add your name, so we know who we answer.German Bitte gib deinen Namen an, damit wir wissen, wem wir antworten.
emailNeededEnglish 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.
phoneNeededEnglish 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.
messageNeededEnglish Please write a few words, so we know what it is about.German Bitte schreib ein paar Worte, damit wir wissen, worum es geht.
fixedAllEnglish All fixed, you can send nowGerman Alles behoben, du kannst jetzt senden
leftEnglish {n} fields still to fixGerman Noch {n} Felder offen
leftOneEnglish One field still to fixGerman Noch ein Feld offen
sendEnglish SendGerman Senden
sentEnglish Sent. Thank you.German Gesendet. Danke.
checkingEnglish Checking {u}German Prüfe {u}
takenEnglish {u} is taken. How about {s}?German {u} ist vergeben. Wie wäre es mit {s}?
freeEnglish {u} is freeGerman {u} ist frei
didYouMeanEnglish Did you mean {e}?German Meintest du {e}?
useItEnglish Use {e}German {e} übernehmen
rulesEnglish Your password needsGerman Dein Passwort braucht
rule8English At least 10 charactersGerman Mindestens 10 Zeichen
ruleNumEnglish A numberGerman Eine Ziffer
ruleCaseEnglish Upper and lower case lettersGerman Groß- und Kleinbuchstaben
ruleNameEnglish Not your usernameGerman Nicht deinen Benutzernamen
createEnglish Create accountGerman Konto anlegen
createdEnglish Account createdGerman Konto angelegt
failTitleEnglish We could not send it, but nothing is lostGerman Wir konnten es nicht senden, aber nichts ist verloren
failTextEnglish 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.
retryInEnglish Trying again in {n} secondsGerman Neuer Versuch in {n} Sekunden
retryingEnglish Trying againGerman Neuer Versuch läuft
retryNowEnglish Try again nowGerman Jetzt erneut versuchen
copyEnglish Copy my messageGerman Nachricht kopieren
copiedEnglish Copied to the clipboardGerman In die Zwischenablage kopiert
otherEnglish Or write to {e}German Oder schreib an {e}
expiredTitleEnglish Your session ran out, your form did notGerman Deine Sitzung ist abgelaufen, dein Formular nicht
expiredTextEnglish 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.
continueEnglish Sign in and continueGerman Anmelden und weitermachen
pwNeededEnglish Please enter your password to continue.German Bitte gib dein Passwort ein, um weiterzumachen.
pwWrongEnglish 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.
resetEnglish Reset passwordGerman Passwort zurücksetzen
keptEnglish Kept for youGerman Für dich aufbewahrt
signedInEnglish Welcome back, you can send now.German Willkommen zurück, du kannst jetzt senden.
signedAsEnglish 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
components/ui/error-recovery.tsx
'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.

Often searched

Jump to

Copied to the clipboard