06Typo suggest — computing edit distance to float a 'did you mean?' row
Computing Levenshtein edit distance between the query and each candidate on a 2D DP table, anything at distance 1-2 counts as a typo and surfaces in a 'did you mean?' row with the intended word. It also covers renormalizing case and whitespace before re-measuring, and how tightening the threshold turns suggestions conservative.
Best forForgiving weak spellers
beforeinputCSS custom propertiesaria-expanded

<main class="stage">
<div class="ty" id="ty">
<p class="ty__hint">If it's a typo, it calculates the edit distance and suggests the closest word.</p>
<div class="ty__field" role="combobox" aria-expanded="false" aria-owns="tyList" aria-haspopup="listbox" id="tyField">
<input class="ty__input" id="tyInput" type="text" role="searchbox" aria-autocomplete="list" aria-controls="tyList" aria-label="Search fruit" autocomplete="off" readonly>
</div>
<ul class="ty__list" id="tyList" role="listbox" aria-label="Candidate list"></ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue-deep;
// 파스텔 무대(도구/파스텔.py to_pastel 결과: 채도 .34 · 명도 +.11, $stage-yellow 기준)
$p-yellow: #d2c7a4;
@include stage($p-yellow);
.ty {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.ty__hint {
margin: 0;
padding: 0 $sp-1;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba($subject-ink, .72);
}
.ty__field {
background: $subject-cream;
border-radius: $r-control;
padding: $sp-2 $sp-3;
box-shadow: $shadow-press;
}
.ty__input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
font-weight: 500;
font-size: 13px;
color: $subject-ink;
caret-color: $color;
}
.ty__list {
margin: 0;
padding: $sp-1;
list-style: none;
display: flex;
flex-direction: column;
gap: $sp-1;
max-height: 132px;
overflow-y: auto;
background: $subject-cream;
border-radius: $r-card;
box-shadow: $shadow-press;
}
.ty__item {
padding: $sp-1 $sp-2;
border-radius: $r-sm;
font-weight: 500;
font-size: 12px;
line-height: 1.6;
color: $subject-ink;
}
.ty__item.is-exact {
background: $color;
color: $subject-cream;
}
.ty__item--empty {
color: rgba($subject-ink, .72);
font-weight: 400;
}
.ty__item--typo {
--emph: 1;
display: flex;
flex-direction: column;
gap: $sp-1;
border-left: calc(2px + 2px * var(--emph)) solid $color;
padding-left: $sp-2;
border-radius: $r-sm;
transition: background-color $duration $easing, border-left-width $duration $easing;
}
.ty__typoLabel {
font-weight: 400;
font-size: 10px;
color: rgba($subject-ink, .72);
}
.ty__typoWord {
font-weight: 700;
font-size: 13px;
color: $color;
}
@media (max-width: 340px) {
.ty { max-width: 260px; }
.ty__list { max-height: 100px; }
}
@media (prefers-reduced-motion: reduce) {
.ty__item--typo {
transition: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #d2c7a4;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ty {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: 8px;
}
.ty__hint {
margin: 0;
padding: 0 4px;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
}
.ty__field {
background: #fff7e6;
border-radius: 12px;
padding: 8px 12px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ty__input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
font-weight: 500;
font-size: 13px;
color: #17141a;
caret-color: #0a4fe8;
}
.ty__list {
margin: 0;
padding: 4px;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
max-height: 132px;
overflow-y: auto;
background: #fff7e6;
border-radius: 16px;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ty__item {
padding: 4px 8px;
border-radius: 8px;
font-weight: 500;
font-size: 12px;
line-height: 1.6;
color: #17141a;
}
.ty__item.is-exact {
background: #0a4fe8;
color: #fff7e6;
}
.ty__item--empty {
color: rgba(23, 20, 26, 0.72);
font-weight: 400;
}
.ty__item--typo {
--emph: 1;
display: flex;
flex-direction: column;
gap: 4px;
border-left: calc(2px + 2px * var(--emph)) solid #0a4fe8;
padding-left: 8px;
border-radius: 8px;
transition: background-color 220ms ease-out, border-left-width 220ms ease-out;
}
.ty__typoLabel {
font-weight: 400;
font-size: 10px;
color: rgba(23, 20, 26, 0.72);
}
.ty__typoWord {
font-weight: 700;
font-size: 13px;
color: #0a4fe8;
}
@media (max-width: 340px) {
.ty {
max-width: 260px;
}
.ty__list {
max-height: 100px;
}
}
@media (prefers-reduced-motion: reduce) {
.ty__item--typo {
transition: none;
}
}// 오타 제안(06) — 입력어와 후보 각각 사이의 레벤슈타인 편집거리를 2차원 DP 표로 계산해
// 최소 거리 후보를 찾는다. 거리 0 이면 정확히 일치, 1~THRESHOLD 이면 "Did you mean this?"
// 줄에 원래 단어를 제안, 그보다 크면 무시한다. 비교 전 toLowerCase + trim 으로
// 대소문자·공백을 정규화한 뒤 다시 거리를 잰다.
(function () {
var field = document.getElementById('tyField');
var input = document.getElementById('tyInput');
var list = document.getElementById('tyList');
if (!field || !input || !list) return;
var WORDS = ['banana', 'papaya', 'avocado', 'pineapple', 'grapefruit', 'cherry', 'peach', 'watermelon', 'strawberry', 'mango'];
var THRESHOLD = 2;
function normalize(s) {
return s.toLowerCase().trim();
}
function editDistance(a, b) {
var m = a.length, n = b.length;
var dp = [];
for (var i = 0; i <= m; i++) { dp.push([i]); }
for (var j = 0; j <= n; j++) { dp[0][j] = j; }
for (i = 1; i <= m; i++) {
for (j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1];
else {
dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
}
return dp[m][n];
}
function nearest(query) {
var q = normalize(query);
var best = null, bestDist = Infinity;
WORDS.forEach(function (w) {
var d = editDistance(q, normalize(w));
if (d < bestDist) { bestDist = d; best = w; }
});
return { word: best, dist: bestDist };
}
function render(query) {
if (!query) {
list.innerHTML = WORDS.map(function (w) {
return '<li class="ty__item" role="option">' + w + '</li>';
}).join('');
field.setAttribute('aria-expanded', 'false');
return;
}
var q = normalize(query);
var exact = WORDS.filter(function (w) { return normalize(w).indexOf(q) !== -1; });
var rows = '';
if (exact.length) {
rows = exact.map(function (w) {
return '<li class="ty__item is-exact" role="option">' + w + '</li>';
}).join('');
} else {
var n = nearest(query);
if (n.dist >= 1 && n.dist <= THRESHOLD) {
var emph = (THRESHOLD - n.dist + 1) / THRESHOLD;
var alpha = (0.08 + 0.14 * emph).toFixed(3);
rows = '<li class="ty__item ty__item--typo" role="option" aria-live="polite" style="--emph:' + emph.toFixed(2) + ';background:rgba(10,79,232,' + alpha + ')">' +
'<span class="ty__typoLabel">Did you mean this?</span>' +
'<span class="ty__typoWord">' + n.word + '</span></li>';
} else {
rows = '<li class="ty__item ty__item--empty">No matching fruit.</li>';
}
}
list.innerHTML = rows;
field.setAttribute('aria-expanded', 'true');
}
render('');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
function stop() { playing = false; }
input.addEventListener('beforeinput', stop);
input.addEventListener('input', function () { render(input.value); });
input.addEventListener('pointerdown', stop);
var STEPS = [
{ q: '', ms: 260 },
{ q: 'banana', ms: 420 },
{ q: 'bandna', ms: 560 },
{ q: '', ms: 220 },
{ q: 'watermelon', ms: 420 },
{ q: 'watermellon', ms: 560 }
];
if (reduce) {
input.value = STEPS[2].q;
render(STEPS[2].q);
} else {
var seg = 0, segStart = performance.now();
(function step(now) {
if (playing) {
var elapsed = now - segStart;
if (elapsed >= STEPS[seg].ms) {
seg = (seg + 1) % STEPS.length;
segStart = now;
}
var q = STEPS[seg].q;
if (input.value !== q) { input.value = q; render(q); }
}
requestAnimationFrame(step);
})(segStart);
}
})();// SearchAutocompleteTypoSuggestDistance — vanilla 판(run/335/demo/06-search-autocomplete-typo-suggest-distance/index.html)
// 의 React 포트. 입력어와 후보 사이의 레벤슈타인 편집거리를 2차원 DP 표로 계산해
// 최소 거리 후보를 찾는다. 거리 0 이면 정확히 일치, 1~THRESHOLD 이면 "이것을
// 찾으셨나요?" 줄에 원래 단어를 제안한다.
import { useEffect, useRef } from "react";
import styles from "./SearchAutocompleteTypoSuggestDistance.module.scss";
export type SearchAutocompleteTypoSuggestDistanceProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "오타면 편집거리를 계산해 가장 가까운 단어를 제안합니다";
const ARIA_LABEL = "과일 검색";
const LIST_LABEL = "후보 목록";
const DID_YOU_MEAN = "이것을 찾으셨나요?";
const NO_MATCH = "일치하는 과일이 없습니다";
const WORDS = ["바나나", "파파야", "아보카도", "파인애플", "자몽", "체리", "복숭아", "수박", "딸기", "망고"];
const THRESHOLD = 2;
function normalize(s: string): string {
return s.toLowerCase().trim();
}
function editDistance(a: string, b: string): number {
const m = a.length;
const n = b.length;
const dp: number[][] = [];
for (let i = 0; i <= m; i++) {
dp.push([i]);
}
for (let j = 0; j <= n; j++) {
dp[0][j] = j;
}
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) dp[i][j] = dp[i - 1][j - 1];
else dp[i][j] = 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
}
}
return dp[m][n];
}
function nearest(query: string): { word: string; dist: number } {
const q = normalize(query);
let best = "";
let bestDist = Infinity;
WORDS.forEach((w) => {
const d = editDistance(q, normalize(w));
if (d < bestDist) {
bestDist = d;
best = w;
}
});
return { word: best, dist: bestDist };
}
const STEPS: { q: string; ms: number }[] = [
{ q: "", ms: 260 },
{ q: "바나나", ms: 420 },
{ q: "바다나", ms: 560 },
{ q: "", ms: 220 },
{ q: "수박", ms: 420 },
{ q: "슈박", ms: 560 }
];
export default function SearchAutocompleteTypoSuggestDistance({
duration = "220ms",
easing = "ease-out",
color = "#0a4fe8"
}: SearchAutocompleteTypoSuggestDistanceProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const listRef = useRef<HTMLUListElement | null>(null);
useEffect(() => {
const field = fieldRef.current;
const input = inputRef.current;
const list = listRef.current;
if (!field || !input || !list) return;
function render(query: string): void {
if (!query) {
list!.innerHTML = WORDS.map((w) => '<li class="' + styles.item + '" role="option">' + w + "</li>").join("");
field!.setAttribute("aria-expanded", "false");
return;
}
const q = normalize(query);
const exact = WORDS.filter((w) => normalize(w).indexOf(q) !== -1);
let rows = "";
if (exact.length) {
rows = exact.map((w) => '<li class="' + styles.item + " " + styles.isExact + '" role="option">' + w + "</li>").join("");
} else {
const n = nearest(query);
if (n.dist >= 1 && n.dist <= THRESHOLD) {
const emph = (THRESHOLD - n.dist + 1) / THRESHOLD;
const alpha = (0.08 + 0.14 * emph).toFixed(3);
rows =
'<li class="' + styles.item + " " + styles.itemTypo + '" role="option" aria-live="polite" style="--emph:' +
emph.toFixed(2) + ";background:rgba(10,79,232," + alpha + ')">' +
'<span class="' + styles.typoLabel + '">' + DID_YOU_MEAN + "</span>" +
'<span class="' + styles.typoWord + '">' + n.word + "</span></li>";
} else {
rows = '<li class="' + styles.item + " " + styles.itemEmpty + '">' + NO_MATCH + "</li>";
}
}
list!.innerHTML = rows;
field!.setAttribute("aria-expanded", "true");
}
render("");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
function stop(): void {
playing = false;
}
function onInput(): void {
render(input!.value);
}
input.addEventListener("beforeinput", stop);
input.addEventListener("input", onInput);
input.addEventListener("pointerdown", stop);
let raf = 0;
if (reduce) {
input.value = STEPS[2].q;
render(STEPS[2].q);
} else {
let seg = 0;
let segStart = performance.now();
const step = (now: number): void => {
if (playing) {
const elapsed = now - segStart;
if (elapsed >= STEPS[seg].ms) {
seg = (seg + 1) % STEPS.length;
segStart = now;
}
const q = STEPS[seg].q;
if (input!.value !== q) {
input!.value = q;
render(q);
}
}
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
return () => {
cancelAnimationFrame(raf);
input.removeEventListener("beforeinput", stop);
input.removeEventListener("input", onInput);
input.removeEventListener("pointerdown", stop);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.ty}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.field} ref={fieldRef} role="combobox" aria-expanded="false" aria-owns="tyListReact" aria-haspopup="listbox">
<input
className={styles.input}
ref={inputRef}
type="text"
role="searchbox"
aria-autocomplete="list"
aria-controls="tyListReact"
aria-label={ARIA_LABEL}
autoComplete="off"
readOnly
style={{ caretColor: color, transitionDuration: duration, transitionTimingFunction: easing }}
/>
</div>
<ul className={styles.list} id="tyListReact" ref={listRef} role="listbox" aria-label={LIST_LABEL} />
</div>
</main>
);
}06 Typo suggest — computing edit distance to float a 'did you mean?' row — Measured area 45.977% · moving frames 7/23Original by GODRICH · SCSS and React ship in the zip · Autocomplete Input: 9 Search Dropdowns
The post this demo lives in
Autocomplete Input: 9 Search DropdownsAutocomplete input, explained: Bolds where letters sit in each suggestion, debounces requests, cycles the list by keyboa See all demos in this post
If you like this effect - similar demos
Inputs05 Payment method card that opens only the option you pickedCheckout Page Design — 9 Cart Patterns, No Library
Inputs09 Tag Input That Ranks Suggestions by Closeness to What You Typed9 Tag Input UI Patterns With CSS Chips
Inputs06 Vertical wizard that folds finished steps into one line9 Multi Step Form UI Patterns — Branch and Resume