{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"email-typo-fix","type":"registry:component","title":"Email typo fix","description":"Catches gmial.com and fixes it by moving only the letters that were wrong.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion","hotmail"],"registryDependencies":["utils"],"files":[{"path":"components/email-typo-fix.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { AnimatePresence, LayoutGroup, motion } from \"motion/react\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// The domains people actually sign up with, most common first so a tie in\n// distance goes to the likelier one.\nconst COMMON = [\n  \"gmail.com\",\n  \"yahoo.com\",\n  \"hotmail.com\",\n  \"outlook.com\",\n  \"icloud.com\",\n  \"aol.com\",\n  \"live.com\",\n  \"msn.com\",\n  \"proton.me\",\n  \"protonmail.com\",\n  \"me.com\",\n  \"mac.com\",\n  \"googlemail.com\",\n  \"yandex.com\",\n  \"gmx.com\",\n  \"hey.com\",\n  \"fastmail.com\",\n  \"zoho.com\",\n  \"comcast.net\",\n  \"yahoo.co.uk\",\n  \"hotmail.co.uk\",\n];\n\n// Real domains that sit one letter from a common one. Never \"correct\" them.\nconst KNOWN = new Set([\n  ...COMMON,\n  \"ymail.com\",\n  \"mail.com\",\n  \"email.com\",\n  \"gmx.de\",\n  \"gmx.net\",\n  \"web.de\",\n  \"live.co.uk\",\n  \"pm.me\",\n  \"hey.co\",\n]);\n\n// Endings that are almost always a slipped .com, .net or .org.\nconst TLD_SLIPS: Record<string, string> = {\n  con: \"com\",\n  cmo: \"com\",\n  ocm: \"com\",\n  vom: \"com\",\n  xom: \"com\",\n  comm: \"com\",\n  cpm: \"com\",\n  cim: \"com\",\n  nte: \"net\",\n  ent: \"net\",\n  ner: \"net\",\n  ogr: \"org\",\n  rog: \"org\",\n  orh: \"org\",\n};\n\nconst VALID = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/;\n\n// Optimal string alignment: Levenshtein plus swapped neighbours, so\n// \"hotmial\" is one slip from \"hotmail\", not two.\nfunction distance(a: string, b: string) {\n  const d = Array.from({ length: a.length + 1 }, (_, i) =>\n    Array.from({ length: b.length + 1 }, (_, j) =>\n      i === 0 ? j : j === 0 ? i : 0,\n    ),\n  );\n  for (let i = 1; i <= a.length; i++) {\n    for (let j = 1; j <= b.length; j++) {\n      const cost = a[i - 1] === b[j - 1] ? 0 : 1;\n      d[i][j] = Math.min(\n        d[i - 1][j] + 1,\n        d[i][j - 1] + 1,\n        d[i - 1][j - 1] + cost,\n      );\n      if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {\n        d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);\n      }\n    }\n  }\n  return d[a.length][b.length];\n}\n\nexport function suggestEmail(email: string): string | null {\n  const at = email.lastIndexOf(\"@\");\n  if (at < 1) return null;\n  const local = email.slice(0, at);\n  const domain = email.slice(at + 1).toLowerCase();\n  if (domain.length < 4 || !domain.includes(\".\") || KNOWN.has(domain)) {\n    return null;\n  }\n  let best: string | null = null;\n  let bestDistance = Infinity;\n  for (const candidate of COMMON) {\n    const d = distance(domain, candidate);\n    if (d < bestDistance) {\n      best = candidate;\n      bestDistance = d;\n    }\n  }\n  // Two slips are only believable in a long name; \"me.co\" is not \"mac.com\".\n  if (best && bestDistance <= (best.length >= 9 ? 2 : 1)) {\n    return `${local}@${best}`;\n  }\n  const dot = domain.lastIndexOf(\".\");\n  const fix = TLD_SLIPS[domain.slice(dot + 1)];\n  return fix ? `${local}@${domain.slice(0, dot)}.${fix}` : null;\n}\n\ntype Glyph = { key: string; char: string; kind: \"keep\" | \"move\" | \"new\" };\n\n// How the old text becomes the new: letters in the longest common run stay\n// put, a letter that only changed places slides to its new spot, and the\n// rest flip out or in. Keys carry identity across the two renders.\nfunction morphPlan(from: string, to: string) {\n  const n = from.length;\n  const m = to.length;\n  const lcs = Array.from({ length: n + 1 }, () =>\n    new Array<number>(m + 1).fill(0),\n  );\n  for (let i = n - 1; i >= 0; i--) {\n    for (let j = m - 1; j >= 0; j--) {\n      lcs[i][j] =\n        from[i] === to[j]\n          ? lcs[i + 1][j + 1] + 1\n          : Math.max(lcs[i + 1][j], lcs[i][j + 1]);\n    }\n  }\n  const kept = new Map<number, number>();\n  for (let i = 0, j = 0; i < n && j < m;) {\n    if (from[i] === to[j]) kept.set(j++, i++);\n    else if (lcs[i + 1][j] >= lcs[i][j + 1]) i++;\n    else j++;\n  }\n  const used = new Set(kept.values());\n  const before: Glyph[] = [...from].map((char, i) => ({\n    key: `k${i}`,\n    char,\n    kind: \"keep\",\n  }));\n  const after: Glyph[] = [...to].map((char, j) => {\n    const i = kept.get(j);\n    if (i !== undefined) return { key: `k${i}`, char, kind: \"keep\" };\n    const spare = [...from].findIndex((c, k) => c === char && !used.has(k));\n    if (spare >= 0) {\n      used.add(spare);\n      return { key: `k${spare}`, char, kind: \"move\" };\n    }\n    return { key: `n${j}`, char, kind: \"new\" };\n  });\n  return { before, after };\n}\n\n// range: the fixed stretch of the new address, underlined as it lands.\ntype Morph = {\n  from: string;\n  to: string;\n  stage: 0 | 1;\n  range: [number, number];\n};\n\n// The letters land by ~600ms (up to four flips, 40ms apart); the underline\n// under the fixed letters then lingers and fades, ending at 1300ms, so you\n// can see what changed after the motion stops.\nconst MORPH_MS = 1300;\n\nexport function EmailTypoFix({\n  value: controlled,\n  onValueChange,\n  onSubmit,\n  label = \"Work email\",\n  hint = \"We will send a sign-in link here.\",\n  submitLabel = \"Create account\",\n  className,\n}: {\n  value?: string;\n  onValueChange?: (value: string) => void;\n  onSubmit?: (email: string) => void;\n  label?: string;\n  hint?: string;\n  submitLabel?: string;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const id = useId();\n  const [own, setOwn] = useState(\"\");\n  const value = controlled ?? own;\n  const setValue = (next: string) => {\n    if (controlled === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n  // The last value we looked at for typos. Typing moves ahead of it, so a\n  // half typed \"gmail.co\" is never flagged mid-word.\n  // Starts equal to the value, so an address handed in pre-filled (a\n  // paste, autofill, a saved draft) is checked on the first render.\n  const [checked, setChecked] = useState(value);\n  const [dismissed, setDismissed] = useState<string[]>([]);\n  const [morph, setMorph] = useState<Morph | null>(null);\n  const [sent, setSent] = useState(\"\");\n  const [previous, setPrevious] = useState(value);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const timers = useRef<number[]>([]);\n\n  // A paste or autofill lands several letters at once: check it right away.\n  // A keystroke extends or trims the old value; anything else (a paste, a\n  // replaced domain) is a whole new address.\n  if (previous !== value) {\n    setPrevious(value);\n    const keystroke =\n      Math.abs(value.length - previous.length) <= 1 &&\n      (value.startsWith(previous) || previous.startsWith(value));\n    if (!keystroke) setChecked(value);\n    // Replaced from outside mid-morph: drop the overlay so it never paints\n    // letters of an address that is no longer there.\n    if (morph && value !== morph.to) setMorph(null);\n  }\n\n  useEffect(() => {\n    if (checked === value) return;\n    // A pause in typing reads as \"done with this bit\".\n    const t = window.setTimeout(() => setChecked(value), 900);\n    return () => window.clearTimeout(t);\n  }, [value, checked]);\n\n  useEffect(() => {\n    const pending = timers.current;\n    return () => pending.forEach((t) => window.clearTimeout(t));\n  }, []);\n\n  const suggestion =\n    checked === value && !dismissed.includes(value.toLowerCase())\n      ? suggestEmail(value.trim())\n      : null;\n  const valid = VALID.test(value.trim());\n\n  const accept = () => {\n    if (!suggestion) return;\n    const input = inputRef.current;\n    const from = value.trim();\n    // Morph only when the whole address is visible; a scrolled field would\n    // put the overlay's letters in the wrong place.\n    const fits = input ? input.scrollWidth <= input.clientWidth : false;\n    setValue(suggestion);\n    setChecked(suggestion);\n    if (!reduceMotion && fits) {\n      setMorph({ from, to: suggestion, stage: 0, range: [fix.start, fix.end] });\n      // Two frames: the overlay first paints the old text exactly over the\n      // field's, then the letters move.\n      requestAnimationFrame(() =>\n        requestAnimationFrame(() =>\n          setMorph((m) => (m ? { ...m, stage: 1 } : m)),\n        ),\n      );\n      timers.current.push(window.setTimeout(() => setMorph(null), MORPH_MS));\n    }\n    requestAnimationFrame(() => {\n      input?.focus();\n      input?.setSelectionRange(suggestion.length, suggestion.length);\n    });\n  };\n\n  const morphGlyphs = morph\n    ? morph.stage === 0\n      ? morphPlan(morph.from, morph.to).before\n      : morphPlan(morph.from, morph.to).after\n    : [];\n  let changed = 0;\n\n  // The stretch of the suggestion that differs, trimmed of the shared start\n  // and end, so a swap underlines both letters rather than one.\n  const fix = { start: 0, end: 0 };\n  if (suggestion) {\n    const from = value.trim();\n    while (\n      fix.start < Math.min(from.length, suggestion.length) &&\n      from[fix.start] === suggestion[fix.start]\n    ) {\n      fix.start++;\n    }\n    let tail = 0;\n    while (\n      tail < Math.min(from.length, suggestion.length) - fix.start &&\n      from[from.length - 1 - tail] === suggestion[suggestion.length - 1 - tail]\n    ) {\n      tail++;\n    }\n    fix.end = Math.max(fix.start + 1, suggestion.length - tail);\n  }\n\n  return (\n    <form\n      noValidate\n      className={cn(\n        \"flex w-[min(400px,100%)] flex-col rounded-[20px] bg-background p-6 shadow-raised\",\n        className,\n      )}\n      onSubmit={(e) => {\n        e.preventDefault();\n        if (!valid) return;\n        setSent(value.trim());\n        onSubmit?.(value.trim());\n      }}\n    >\n      <h2 className=\"text-lg font-semibold text-foreground\">\n        Start your free trial\n      </h2>\n      <p className=\"mt-1 text-sm text-muted-foreground\">\n        14 days of everything. No card needed.\n      </p>\n\n      <label htmlFor={id} className=\"mt-5 text-sm font-medium text-foreground\">\n        {label}\n      </label>\n      <div className=\"relative mt-2\">\n        <input\n          ref={inputRef}\n          id={id}\n          // Text, not email: email inputs refuse setSelectionRange, and the\n          // caret has to land at the end after a fix.\n          type=\"text\"\n          inputMode=\"email\"\n          autoComplete=\"email\"\n          autoCapitalize=\"none\"\n          spellCheck={false}\n          placeholder=\"you@company.com\"\n          value={value}\n          aria-describedby={`${id}-note`}\n          onChange={(e) => {\n            setMorph(null);\n            setSent(\"\");\n            setValue(e.target.value);\n          }}\n          onBlur={() => setChecked(value)}\n          className={cn(\n            // Kerning and ligatures off, here and in the overlay, so letters\n            // set one span at a time land exactly where the field draws them.\n            \"h-11 w-full rounded-[10px] bg-muted px-3.5 text-[15px] text-foreground outline-hidden [font-kerning:none] [font-variant-ligatures:none] placeholder:text-muted-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground\",\n            morph && \"text-transparent caret-transparent\",\n          )}\n        />\n        {morph && (\n          <LayoutGroup id={`${id}-morph`}>\n            <span\n              aria-hidden\n              className=\"pointer-events-none absolute inset-0 flex items-center px-3.5 text-[15px] whitespace-pre text-foreground [font-kerning:none] [font-variant-ligatures:none]\"\n            >\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                {morphGlyphs.map((glyph, index) => {\n                  const order = glyph.kind === \"keep\" ? 0 : changed++;\n                  return (\n                    <motion.span\n                      key={glyph.key}\n                      layout=\"position\"\n                      className=\"relative inline-block\"\n                      style={{ transformPerspective: 120 }}\n                      initial={{ opacity: 0, rotateX: -90, y: \"30%\" }}\n                      animate={{\n                        opacity: 1,\n                        rotateX: 0,\n                        // A moved letter hops over its neighbour instead of\n                        // sliding through it.\n                        y: glyph.kind === \"move\" ? [\"0%\", \"-40%\", \"0%\"] : \"0%\",\n                      }}\n                      exit={{\n                        opacity: 0,\n                        rotateX: 90,\n                        y: \"-30%\",\n                        transition: {\n                          duration: 0.16,\n                          ease: [0.23, 1, 0.32, 1],\n                        },\n                      }}\n                      transition={{\n                        type: \"spring\",\n                        visualDuration: 0.38,\n                        bounce: 0,\n                        delay: order * 0.04,\n                        y: { duration: 0.42, ease: [0.77, 0, 0.175, 1] },\n                      }}\n                    >\n                      {glyph.char}\n                      {morph?.stage === 1 &&\n                        index >= morph.range[0] &&\n                        index < morph.range[1] && (\n                          // The same underline the suggestion used, left under\n                          // the stretch it fixed, then faded out.\n                          // Plain WAAPI, not motion: the glyph lives under an\n                          // AnimatePresence with initial={false}, which would\n                          // make a motion child start on its last keyframe.\n                          <span\n                            className=\"absolute inset-x-0 -bottom-[3px] h-0.5 rounded-full bg-foreground/40 opacity-0\"\n                            ref={(el) => {\n                              const run = el?.animate(\n                                [\n                                  { opacity: 0 },\n                                  { opacity: 1, offset: 0.25 },\n                                  { opacity: 1, offset: 0.6 },\n                                  { opacity: 0 },\n                                ],\n                                {\n                                  duration: 1100,\n                                  delay: 240,\n                                  easing: \"ease-out\",\n                                },\n                              );\n                              return () => run?.cancel();\n                            }}\n                          />\n                        )}\n                    </motion.span>\n                  );\n                })}\n              </AnimatePresence>\n            </span>\n          </LayoutGroup>\n        )}\n      </div>\n\n      {/* One fixed-height slot for the hint, the suggestion and the sent\n          note, so the button below never moves. */}\n      <div id={`${id}-note`} aria-live=\"polite\" className=\"relative mt-2 h-10\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {suggestion ? (\n            <motion.div\n              key=\"suggest\"\n              className=\"absolute inset-0 flex items-center gap-1\"\n              initial={\n                reduceMotion\n                  ? { opacity: 0 }\n                  : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n              }\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{\n                opacity: 0,\n                filter: \"blur(2px)\",\n                transition: { duration: 0.12 },\n              }}\n              transition={{ duration: 0.22, ease: [0.23, 1, 0.32, 1] }}\n            >\n              <button\n                type=\"button\"\n                onClick={accept}\n                className=\"-ml-2 flex h-10 min-w-0 flex-1 items-center rounded-lg px-2 text-left text-sm leading-5 text-muted-foreground outline-hidden transition-[scale,background-color] duration-150 ease-out hover:bg-muted focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n              >\n                {/* Wraps to a second line on a phone rather than cutting\n                    off the one part that matters. */}\n                <span className=\"line-clamp-2 [overflow-wrap:anywhere]\">\n                  Did you mean{\" \"}\n                  <span className=\"text-foreground\">\n                    {suggestion.slice(0, fix.start)}\n                    <span className=\"font-semibold underline decoration-foreground/40 decoration-2 underline-offset-[3px]\">\n                      {suggestion.slice(fix.start, fix.end)}\n                    </span>\n                    {suggestion.slice(fix.end)}\n                  </span>\n                  ?\n                </span>\n              </button>\n              <button\n                type=\"button\"\n                aria-label={`No, keep ${value.trim()}`}\n                onClick={() => {\n                  setDismissed((d) => [...d, value.toLowerCase()]);\n                  inputRef.current?.focus();\n                }}\n                className=\"flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground outline-hidden transition-[scale,color,background-color] duration-150 ease-out hover:bg-muted hover:text-foreground focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96]\"\n              >\n                <svg\n                  viewBox=\"0 0 16 16\"\n                  aria-hidden\n                  className=\"size-3.5\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth={1.5}\n                  strokeLinecap=\"round\"\n                >\n                  <path d=\"m4.5 4.5 7 7M11.5 4.5l-7 7\" />\n                </svg>\n              </button>\n            </motion.div>\n          ) : (\n            <motion.p\n              key={sent ? `sent-${sent}` : \"hint\"}\n              className=\"absolute inset-0 flex items-center text-[13px] text-muted-foreground\"\n              initial={\n                reduceMotion\n                  ? { opacity: 0 }\n                  : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n              }\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ opacity: 0, transition: { duration: 0.12 } }}\n              transition={{ duration: 0.22, ease: [0.23, 1, 0.32, 1] }}\n            >\n              <span className=\"truncate\">\n                {sent ? (\n                  <>\n                    Link sent to <span className=\"text-foreground\">{sent}</span>\n                    .\n                  </>\n                ) : (\n                  hint\n                )}\n              </span>\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </div>\n\n      <button\n        type=\"submit\"\n        disabled={!valid}\n        className=\"mt-2 h-11 rounded-[10px] bg-foreground text-[15px] font-medium text-background outline-hidden transition-[scale,opacity] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground enabled:active:scale-[0.96] disabled:opacity-35\"\n      >\n        {submitLabel}\n      </button>\n    </form>\n  );\n}\n\n// Four slips of different shapes: a swap, a swap mid-word, a dropped\n// letter and a wrong key in the ending.\nconst TYPOS = [\"gmial.com\", \"hotmial.com\", \"yaho.com\", \"gmail.con\"];\nconst NAME = \"maya.chen@\";\n\nexport default function EmailTypoFixDemo() {\n  // Opens on a typo so the suggestion is the first thing you see.\n  const [value, setValue] = useState(`${NAME}${TYPOS[0]}`);\n  return (\n    <div className=\"flex w-full flex-col items-center gap-5\">\n      <EmailTypoFix value={value} onValueChange={setValue} />\n      <div className=\"flex w-[min(400px,100%)] flex-col items-center gap-2\">\n        <span className=\"text-[13px] text-muted-foreground\">Try another typo</span>\n        {/* Two by two on a phone rather than three and an orphan. */}\n        <div className=\"grid grid-cols-2 gap-1.5 sm:flex\">\n          {TYPOS.map((typo) => (\n            <button\n              key={typo}\n              type=\"button\"\n              aria-pressed={value === `${NAME}${typo}`}\n              onClick={() => setValue(`${NAME}${typo}`)}\n              className=\"h-8 rounded-full bg-muted px-3 font-mono text-[12px] text-foreground outline-hidden transition-[scale,background-color,color] duration-150 ease-out hover:bg-foreground/[0.08] focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-foreground active:scale-[0.96] aria-pressed:bg-foreground aria-pressed:text-background\"\n            >\n              {typo}\n            </button>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["inputs"],"docs":"From ui lab: https://lab.xevrion.dev/lab/email-typo-fix"}