Input
Email input patterns — inline typo detection for the domain part.
Examples
Domain typo detection
Bounce rates on signup flows are dominated by two things: people mistyping
their domain (gmial.com, yahho.com, gmail.con) and people using an
address they can't receive mail at. You can't fix the second from the
client, but you can catch the first with a tiny edit-distance check against
a list of popular domains.
This demo compares the typed domain against ~20 common providers using
Damerau–Levenshtein distance (which counts insertions, deletions,
substitutions, and transpositions of adjacent characters — important for
typos like gmial ↔ gmail). A suggestion is surfaced when the closest
match is within 2 edits; clicking it replaces the value.
Did you mean ?
"use client";
import * as React from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
const DOMAINS = [
"gmail.com",
"googlemail.com",
"yahoo.com",
"yahoo.co.uk",
"outlook.com",
"hotmail.com",
"hotmail.co.uk",
"live.com",
"msn.com",
"icloud.com",
"me.com",
"mac.com",
"aol.com",
"protonmail.com",
"proton.me",
"mail.com",
"zoho.com",
"fastmail.com",
"tutanota.com",
"tuta.io",
];
// Damerau–Levenshtein: counts insertions, deletions, substitutions and
// transpositions of adjacent characters — matching the common email typos
// `gmial`, `gmai`, `gnail`, `gmail.con`, `gmail.co`.
function damerauLevenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
if (m === 0) return n;
if (n === 0) return m;
const d: number[][] = Array.from({ length: m + 1 }, () =>
new Array(n + 1).fill(0),
);
for (let i = 0; i <= m; i++) d[i][0] = i;
for (let j = 0; j <= n; j++) d[0][j] = j;
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
d[i][j] = Math.min(
d[i - 1][j] + 1,
d[i][j - 1] + 1,
d[i - 1][j - 1] + cost,
);
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
}
}
}
return d[m][n];
}
function suggestDomain(email: string): string | null {
const at = email.lastIndexOf("@");
if (at < 1 || at === email.length - 1) return null;
const local = email.slice(0, at);
const domain = email.slice(at + 1).toLowerCase();
if (!domain.includes(".") || domain.endsWith(".")) return null;
if (DOMAINS.includes(domain)) return null;
let best: { domain: string; distance: number } | null = null;
for (const candidate of DOMAINS) {
const distance = damerauLevenshtein(domain, candidate);
if (distance === 0 || distance > 2) continue;
if (!best || distance < best.distance) {
best = { domain: candidate, distance };
}
}
return best ? `${local}@${best.domain}` : null;
}
export function EmailTypoDemo() {
const id = React.useId();
const suggestionId = `${id}-suggestion`;
const [value, setValue] = React.useState("jane@gmial.com");
const suggestion = React.useMemo(() => suggestDomain(value), [value]);
return (
<div className="grid w-full gap-2">
<Label htmlFor={id}>Email</Label>
<Input
id={id}
type="email"
autoComplete="email"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="name@example.com"
aria-describedby={suggestion ? suggestionId : undefined}
/>
<p
id={suggestionId}
aria-live="polite"
className="text-muted-foreground min-h-4 text-xs"
>
{suggestion ? (
<>
Did you mean{" "}
<button
type="button"
onClick={() => setValue(suggestion)}
className="text-foreground focus-visible:ring-ring/50 focus-visible:border-ring rounded underline decoration-dotted underline-offset-2 outline-none hover:decoration-solid focus-visible:ring-[3px]"
>
{suggestion}
</button>
?
</>
) : null}
</p>
</div>
);
}Notes:
- Suggest, don't correct. Never silently rewrite what the user typed —
they may have a legitimate
@gmial.examplevanity domain, and a silent rewrite is a much worse bug than a bounced email. - Don't gate submission. The suggestion is a hint, not a validation error. Let the user press submit with their typed value if they want. Require confirmation elsewhere (double-entry, magic-link verification).
- The suggestion line reserves height (
min-h-4) so the input doesn't jump when a suggestion appears or disappears. aria-live="polite"on the hint region means the announcement follows the user's typing instead of interrupting it;aria-describedbyon the input links it to the hint so the clickable suggestion is read when the field is focused.- The accept action is a real
<button>— keyboard-focusable, activatable with Enter/Space. It replaces the value via the samesetValuethe input uses, so it works unchanged with React Hook Form or any other binding. - Damerau–Levenshtein (not plain Levenshtein) is the right metric here
because the common typos are swaps of adjacent keys:
gmial,yhaoo,proton.em. Plain Levenshtein counts those as distance 2, not 1. - The domain list is deliberately short — ~20 domains cover >95% of consumer email in most regions. A fuller list (see mailcheck's default list) adds noise: with more candidates inside distance 2 of a short string, you start suggesting wrong domains for correctly typed rare ones.
- Case the local part as the user typed it; lowercase only the domain for
comparison.
Jane@Gmial.comshould be suggested asJane@gmail.com, notjane@gmail.com. - This implementation runs Damerau–Levenshtein against every candidate on every keystroke. That's fine for ~20 domains and 8–30-char inputs — it measures in microseconds. If you grow the list to thousands (full IANA TLDs, vanity domains, corporate addressbook), swap the linear scan for an indexed structure like mnemonist's PassjoinIndex, which prunes candidates by shared substring partitions and only computes edit distance for the shortlist.
- If you need more than this, reach for
mailcheckoremail-misspelled— they ship curated domain lists, TLD splitting, and second-level-domain handling (e.g.yahoo.co→yahoo.co.uk).