Fuzzy Match Input
A fuzzy search field that scores by run length and word starts and lights up the exact letters that matched. Results slide to their new rank as you type, a spring cursor follows the arrow keys, scores get a small bar, and recent picks and a calm no-match state are built in. Dark or light, dependency-free.
DOM + CSS0 deps1 file · 21 KB TSXreduced-motion aware
Props
"use client";
import {
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
} from "react";
type Match = { indices: number[]; score: number } | null;
/**
* Subsequence matcher. Returns the matched INDICES as well as a score -
* returning only a boolean is why most "fuzzy search" fields cannot highlight
* what actually matched.
*/
export function fuzzyMatch(query: string, target: string): Match {
if (!query) return { indices: [], score: 0 };
const q = query.toLowerCase();
const t = target.toLowerCase();
const indices: number[] = [];
let score = 0;
let run = 0;
let ti = 0;
for (let qi = 0; qi < q.length; qi++) {
const ch = q.charAt(qi);
if (ch === " ") continue;
let found = -1;
while (ti < t.length) {
if (t.charAt(ti) === ch) {
found = ti;
break;
}
ti++;
}
if (found === -1) return null; // a query char is missing -> no match
// Consecutive hits are worth far more than scattered ones, so "cmd"
// prefers a contiguous run over three letters spread across the string.
const prev = indices.length > 0 ? (indices[indices.length - 1] ?? -2) : -2;
indices.push(found);
run = found === prev + 1 ? run + 1 : 0;
score += 1 + run * 4;
// Word starts are a strong signal of intent.
if (found === 0 || t.charAt(found - 1) === " " || t.charAt(found - 1) === "-") score += 6;
ti = found + 1;
}
return { indices, score };
}
export type FuzzyMatchInputProps = {
rows: readonly string[];
/** Most rows shown at once. */
limit?: number;
placeholder?: string;
/** Print each row's match score (and a small bar), which makes the ranking legible. */
showScore?: boolean;
/** "dark" paints a near-black panel, "light" a white one. */
tone?: "dark" | "light";
/** Highlight colour (any CSS colour). Defaults to blue. */
accent?: string;
/** Controlled query. */
value?: string;
/** Initial query when uncontrolled. */
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Fires on Enter or click/tap on a row. */
onSelect?: (row: string) => void;
/** Accessible name of the field. */
label?: string;
/** Headline of the no-match state. */
emptyText?: string;
className?: string;
style?: CSSProperties;
};
const ROW = 44;
const MAX_VISIBLE = 8;
const EMPTY_H = 136;
const COLOR_RE = /^(#[0-9a-f]{3,8}|(rgb|hsl)a?\([0-9\s.,%/]+\)|[a-z]{3,20})$/i;
const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
const CSS = [
"@layer components {",
".fmi-root{--fmi-bg:#0b0c0f;--fmi-ring:rgba(255,255,255,.1);--fmi-ink:#f4f4f5;--fmi-mute:rgba(244,244,245,.56);--fmi-faint:rgba(244,244,245,.42);--fmi-pill:rgba(255,255,255,.065);--fmi-accent:#60a5fa;--fmi-shadow:0 30px 70px -28px rgba(0,0,0,.85),0 2px 0 rgba(255,255,255,.04) inset;--fmi-sheen:rgba(255,255,255,.2);position:relative;display:block;width:100%;isolation:isolate;overflow:hidden;border-radius:18px;background:var(--fmi-bg);color:var(--fmi-ink);box-shadow:0 0 0 1px var(--fmi-ring),var(--fmi-shadow);font-size:15px;line-height:1.35;text-align:left;-webkit-tap-highlight-color:transparent}",
".fmi-root[data-tone=light]{--fmi-bg:#ffffff;--fmi-ring:#e4e4e7;--fmi-ink:#18181b;--fmi-mute:#52525b;--fmi-faint:#71717a;--fmi-pill:rgba(24,24,27,.055);--fmi-accent:#2563eb;--fmi-shadow:0 26px 60px -30px rgba(24,24,27,.3);--fmi-sheen:rgba(255,255,255,0)}",
".fmi-root::before{content:'';position:absolute;left:12%;right:12%;top:0;height:1px;background:linear-gradient(90deg,transparent,var(--fmi-sheen),transparent);z-index:3;pointer-events:none}",
".fmi-root:focus-within{box-shadow:0 0 0 1px color-mix(in srgb,var(--fmi-accent) 55%,transparent),0 0 0 4px color-mix(in srgb,var(--fmi-accent) 16%,transparent),var(--fmi-shadow)}",
".fmi-glow{position:absolute;inset:0;z-index:0;pointer-events:none;opacity:0;transition:opacity .35s ease;background:radial-gradient(260px circle at var(--fmi-mx,50%) var(--fmi-my,0%),color-mix(in srgb,var(--fmi-accent) 11%,transparent),transparent 70%)}",
"@media (hover:hover){.fmi-root:hover .fmi-glow{opacity:1}}",
".fmi-in{position:relative;z-index:1;display:flex;align-items:center;gap:10px;height:56px;padding:0 10px 0 18px;border-bottom:1px solid var(--fmi-ring)}",
".fmi-sweep{position:absolute;left:0;right:0;bottom:-1px;height:2px;background:var(--fmi-accent);transform:scaleX(0);transform-origin:left;transition:transform .55s cubic-bezier(.22,1,.36,1)}",
".fmi-root:focus-within .fmi-sweep{transform:scaleX(1)}",
".fmi-ico{flex:none;width:18px;height:18px;color:var(--fmi-faint);transition:color .2s ease,transform .3s cubic-bezier(.34,1.4,.5,1)}",
".fmi-root:focus-within .fmi-ico{color:var(--fmi-accent);transform:scale(1.08)}",
".fmi-input{flex:1;min-width:0;height:100%;background:transparent;border:0;color:var(--fmi-ink);font:inherit;font-size:16px;letter-spacing:-.005em;caret-color:var(--fmi-accent)}",
".fmi-input,.fmi-input:focus,.fmi-input:focus-visible{outline:0!important;box-shadow:none}",
".fmi-input::placeholder{color:var(--fmi-faint);opacity:1}",
".fmi-x{flex:none;display:grid;place-items:center;width:40px;height:40px;border:0;border-radius:10px;background:transparent;color:var(--fmi-faint);cursor:pointer;transition:background .15s ease,color .15s ease,opacity .2s ease,transform .25s cubic-bezier(.34,1.4,.5,1)}",
".fmi-x svg{width:16px;height:16px}",
".fmi-x:hover{background:var(--fmi-pill);color:var(--fmi-ink)}",
".fmi-x:active{transform:scale(.9)}",
".fmi-x[data-off]{opacity:0;pointer-events:none;transform:scale(.7)}",
".fmi-x:focus-visible,.fmi-clear:focus-visible{outline:2px solid var(--fmi-accent)!important;outline-offset:1px!important;border-radius:10px!important}",
".fmi-hd{position:relative;z-index:1;display:flex;align-items:center;justify-content:space-between;height:30px;padding:0 18px;font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--fmi-faint)}",
".fmi-hd b{font-weight:500;font-variant-numeric:tabular-nums}",
".fmi-wrap{position:relative;z-index:1;transition:height .3s cubic-bezier(.22,1,.36,1)}",
".fmi-list{position:relative;height:100%;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;scrollbar-width:none}",
".fmi-list::-webkit-scrollbar{display:none}",
".fmi-pill{position:absolute;left:6px;right:6px;top:0;height:" + ROW + "px;border-radius:12px;background:var(--fmi-pill);pointer-events:none;transition:transform .32s cubic-bezier(.3,1.25,.5,1),opacity .2s ease,background .5s ease}",
".fmi-pill::before{content:'';position:absolute;left:0;top:13px;bottom:13px;width:2px;border-radius:2px;background:var(--fmi-accent)}",
".fmi-pill[data-flash]{background:color-mix(in srgb,var(--fmi-accent) 24%,transparent);transition:transform .32s cubic-bezier(.3,1.25,.5,1),background .08s ease}",
".fmi-row{position:relative;display:flex;align-items:center;gap:10px;height:" + ROW + "px;padding:0 18px;cursor:pointer;touch-action:manipulation}",
".fmi-lead{flex:none;width:14px;height:14px;display:grid;place-items:center;color:var(--fmi-faint)}",
".fmi-lead svg{width:14px;height:14px}",
".fmi-arrow{opacity:0;transform:translateX(-5px);transition:opacity .2s ease,transform .3s cubic-bezier(.22,1,.36,1);color:var(--fmi-accent)}",
".fmi-row[data-active] .fmi-arrow{opacity:1;transform:none}",
".fmi-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
".fmi-c{color:var(--fmi-ink);transition:color .18s ease,box-shadow .18s ease}",
".fmi-root[data-q='1'] .fmi-c{color:var(--fmi-mute)}",
".fmi-root[data-q='1'] .fmi-row[data-active] .fmi-c{color:color-mix(in srgb,var(--fmi-ink) 78%,transparent)}",
".fmi-root[data-q='1'] .fmi-c[data-hit]{color:var(--fmi-accent);box-shadow:inset 0 -1.5px 0 color-mix(in srgb,var(--fmi-accent) 70%,transparent);transition-delay:calc(var(--k,0)*18ms)}",
".fmi-score{flex:none;display:flex;align-items:center;gap:8px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;color:var(--fmi-faint);font-variant-numeric:tabular-nums}",
".fmi-bar{display:block;width:30px;height:3px;border-radius:3px;background:var(--fmi-pill);overflow:hidden}",
".fmi-bar i{display:block;height:100%;width:100%;border-radius:3px;background:var(--fmi-accent);opacity:.8;transform-origin:left;transition:transform .45s cubic-bezier(.22,1,.36,1)}",
".fmi-check{flex:none;width:18px;height:18px;color:var(--fmi-accent);animation:fmi-pop .45s cubic-bezier(.34,1.5,.5,1) both}",
"@keyframes fmi-pop{from{opacity:0;transform:scale(.4)}to{opacity:1;transform:none}}",
".fmi-empty{position:relative;z-index:1;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:0 24px;text-align:center}",
".fmi-empty svg{width:22px;height:22px;color:var(--fmi-faint);margin-bottom:6px}",
".fmi-empty p{margin:0}",
".fmi-empty .t{font-size:14px;color:var(--fmi-ink);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}",
".fmi-empty .s{font-size:12.5px;color:var(--fmi-mute)}",
".fmi-clear{margin-top:10px;height:36px;min-width:40px;padding:0 14px;border:0;border-radius:10px;background:var(--fmi-pill);color:var(--fmi-ink);font:inherit;font-size:12.5px;cursor:pointer;transition:background .15s ease,transform .2s cubic-bezier(.34,1.4,.5,1)}",
".fmi-clear:hover{background:color-mix(in srgb,var(--fmi-accent) 20%,transparent)}",
".fmi-clear:active{transform:scale(.95)}",
".fmi-ft{position:relative;z-index:1;display:flex;align-items:center;gap:16px;height:38px;padding:0 18px;border-top:1px solid var(--fmi-ring);font-size:11.5px;color:var(--fmi-faint)}",
".fmi-ft span{display:inline-flex;align-items:center;gap:6px}",
".fmi-ft kbd{display:inline-grid;place-items:center;min-width:20px;height:20px;padding:0 5px;border-radius:6px;background:var(--fmi-pill);color:var(--fmi-mute);font:inherit;font-size:11px}",
"@media (hover:none){.fmi-ft{display:none}}",
".fmi-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}",
"@media (prefers-reduced-motion:reduce){.fmi-root *,.fmi-root *::before{transition-duration:.01ms!important;transition-delay:0s!important;animation-duration:.01ms!important}}",
"}",
].join("\n");
const Svg = ({ d, className }: { d: string; className?: string }) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<path d={d} />
</svg>
);
type Item = { row: string; indices: number[]; score: number; recent: boolean };
export function FuzzyMatchInput({
rows,
limit = 5,
placeholder = "Search…",
showScore = true,
tone = "dark",
accent,
value,
defaultValue = "",
onValueChange,
onSelect,
label = "Search",
emptyText = "Nothing matches",
className,
style,
}: FuzzyMatchInputProps) {
const uid = useId();
const listId = uid + "-list";
const [inner, setInner] = useState(defaultValue);
const query = value ?? inner;
const [active, setActive] = useState(0);
const [recent, setRecent] = useState<string[]>([]);
const [picked, setPicked] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const prevPos = useRef<Map<string, number>>(new Map());
const timer = useRef(0);
const hasQuery = query.trim().length > 0;
const max = Math.max(1, Math.floor(limit));
const { items, total } = useMemo(() => {
const uniq = Array.from(new Set(rows));
if (!hasQuery) {
const rec = recent.filter((r) => uniq.includes(r)).slice(0, 3);
const list: Item[] = [
...rec.map((row) => ({ row, indices: [], score: 0, recent: true })),
...uniq.filter((r) => !rec.includes(r)).map((row) => ({ row, indices: [], score: 0, recent: false })),
];
return { items: list.slice(0, max), total: uniq.length };
}
const scored: Item[] = [];
for (const row of uniq) {
const m = fuzzyMatch(query, row);
if (m) scored.push({ row, indices: m.indices, score: m.score, recent: false });
}
// Ties: the shorter row is the closer match. Array.sort is stable, so
// equal rows keep their input order.
scored.sort((a, b) => b.score - a.score || a.row.length - b.row.length);
return { items: scored.slice(0, max), total: scored.length };
}, [rows, query, hasQuery, recent, max]);
const n = items.length;
const ai = Math.min(active, Math.max(0, n - 1));
const topScore = items.reduce((m, it) => Math.max(m, it.score), 1);
// Rows slide to their new rank and new rows rise in. Positions are measured
// in layout pixels, so it also works inside a transform-scaled preview.
useIsoLayoutEffect(() => {
const list = listRef.current;
if (!list) return;
const calm = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const next = new Map<string, number>();
const els = list.querySelectorAll<HTMLElement>("[data-row]");
els.forEach((el, idx) => {
const k = el.dataset.row ?? "";
const top = el.offsetTop;
next.set(k, top);
if (calm || typeof el.animate !== "function") return;
const was = prevPos.current.get(k);
if (was === undefined) {
el.animate(
[
{ opacity: 0, transform: "translateY(8px)" },
{ opacity: 1, transform: "none" },
],
{ duration: 260, delay: Math.min(idx, 6) * 28, easing: "cubic-bezier(.22,1,.36,1)", fill: "backwards" },
);
} else if (was !== top) {
el.animate([{ transform: "translateY(" + (was - top) + "px)" }, { transform: "none" }], {
duration: 340,
easing: "cubic-bezier(.3,1.2,.5,1)",
});
}
});
prevPos.current = next;
}, [items]);
const setQuery = (v: string) => {
if (value === undefined) setInner(v);
onValueChange?.(v);
setActive(0);
};
const choose = (row: string) => {
setRecent((r) => [row, ...r.filter((x) => x !== row)].slice(0, 6));
setPicked(row);
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setPicked(null), 1100);
onSelect?.(row);
};
const reveal = (i: number) => {
const list = listRef.current;
if (!list) return;
const top = i * ROW;
if (top < list.scrollTop) list.scrollTop = top;
else if (top + ROW > list.scrollTop + list.clientHeight) list.scrollTop = top + ROW - list.clientHeight;
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.nativeEvent.isComposing) return;
if ((e.key === "ArrowDown" || e.key === "ArrowUp") && n > 0) {
e.preventDefault();
const i = (ai + (e.key === "ArrowDown" ? 1 : n - 1)) % n;
setActive(i);
reveal(i);
} else if (e.key === "Enter") {
const it = items[ai];
if (it) {
e.preventDefault();
choose(it.row);
}
} else if (e.key === "Escape") {
if (query) {
e.preventDefault();
setQuery("");
} else {
e.currentTarget.blur();
}
}
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const el = rootRef.current;
if (!el || e.pointerType !== "mouse") return;
const r = el.getBoundingClientRect();
if (!r.width) return;
const k = el.offsetWidth / r.width;
el.style.setProperty("--fmi-mx", (e.clientX - r.left) * k + "px");
el.style.setProperty("--fmi-my", (e.clientY - r.top) * k + "px");
};
const rootStyle = {
...(accent && COLOR_RE.test(accent.trim()) ? { "--fmi-accent": accent.trim() } : null),
...style,
} as CSSProperties;
const wrapH = n > 0 ? Math.min(n, MAX_VISIBLE) * ROW : EMPTY_H;
const heading = hasQuery ? "Best matches" : recent.length > 0 ? "Recent" : "Suggested";
const status = n > 0 ? (hasQuery ? total + (total === 1 ? " match" : " matches") : n + " suggestions") : "No matches";
return (
<div
ref={rootRef}
className={"fmi-root" + (className ? " " + className : "")}
style={rootStyle}
data-tone={tone === "light" ? "light" : "dark"}
data-q={hasQuery ? "1" : "0"}
onPointerMove={onPointerMove}
>
<style>{CSS}</style>
<div className="fmi-glow" aria-hidden="true" />
<div className="fmi-in">
<svg
className="fmi-ico"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
aria-hidden="true"
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.6-3.6" />
</svg>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
aria-label={label}
role="combobox"
aria-expanded={n > 0}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={n > 0 ? uid + "-o" + ai : undefined}
className="fmi-input"
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
enterKeyHint="go"
/>
<button
type="button"
className="fmi-x"
data-off={query ? undefined : ""}
aria-label="Clear search"
tabIndex={query ? 0 : -1}
onClick={() => {
setQuery("");
inputRef.current?.focus();
}}
>
<Svg d="M18 6 6 18M6 6l12 12" />
</button>
<span className="fmi-sweep" aria-hidden="true" />
</div>
<div className="fmi-hd" aria-hidden="true">
<span>{heading}</span>
<b>{hasQuery && n > 0 ? n + " of " + total : ""}</b>
</div>
<div className="fmi-wrap" style={{ height: wrapH }}>
{n > 0 ? (
<div className="fmi-list" ref={listRef} role="listbox" id={listId} aria-label={label + " results"}>
<div className="fmi-pill" aria-hidden="true" data-flash={picked ? "" : undefined} style={{ transform: "translateY(" + ai * ROW + "px)" }} />
{items.map((it, i) => {
const hit = new Set(it.indices);
let k = 0;
return (
<div
key={it.row}
id={uid + "-o" + i}
role="option"
aria-selected={i === ai}
aria-label={it.row}
data-row={it.row}
data-active={i === ai ? "" : undefined}
className="fmi-row"
onPointerMove={(e) => {
if (e.pointerType === "mouse" && i !== ai) setActive(i);
}}
onClick={() => {
setActive(i);
choose(it.row);
}}
>
<span className="fmi-lead" aria-hidden="true">
{it.recent ? (
<Svg d="M12 7v5l3 2M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
) : (
<Svg className="fmi-arrow" d="M5 12h14M13 6l6 6-6 6" />
)}
</span>
<span className="fmi-text" aria-hidden="true">
{it.row.split("").map((ch, ci) => {
const on = hit.has(ci);
return (
<span
key={ci}
className="fmi-c"
data-hit={on ? "" : undefined}
style={on ? ({ "--k": k++ } as CSSProperties) : undefined}
>
{ch}
</span>
);
})}
</span>
{picked === it.row ? (
<Svg className="fmi-check" d="m5 12.5 4.5 4.5L19 7.5" />
) : showScore && hasQuery ? (
<span className="fmi-score" aria-hidden="true">
<span className="fmi-bar">
<i style={{ transform: "scaleX(" + Math.max(0.08, it.score / topScore) + ")" }} />
</span>
{it.score}
</span>
) : null}
</div>
);
})}
</div>
) : (
<div className="fmi-empty">
<Svg d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14ZM20 20l-4-4M8.5 8.5l5 5M13.5 8.5l-5 5" />
<p className="t">
{emptyText} “{query.trim()}”
</p>
<p className="s">Every letter has to appear in order. Try fewer.</p>
<button
type="button"
className="fmi-clear"
onClick={() => {
setQuery("");
inputRef.current?.focus();
}}
>
Clear search
</button>
</div>
)}
</div>
<div className="fmi-ft" aria-hidden="true">
<span>
<kbd>↑</kbd>
<kbd>↓</kbd> move
</span>
<span>
<kbd>↵</kbd> pick
</span>
<span>
<kbd>esc</kbd> clear
</span>
</div>
<div className="fmi-sr" role="status" aria-live="polite">
{status}
</div>
</div>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
Most in-app search is `String.includes` with a lowercase call, which cannot match “cmd” to “Command Menu” and cannot tell you which characters to highlight. Subsequence matching fixes the first problem: walk the query in order, advancing a pointer through the target, and a match exists if every query character can be found in sequence. Returning the matched indices rather than a boolean fixes the second, because the highlight is then a per-character render against that index set. Scoring is what makes it usable: consecutive characters score progressively higher than scattered ones, and a match at a word start earns a flat bonus, so initialisms behave the way people expect. Ties go to the shorter row. Around the matcher sits a finished field: matched letters light up in a short cascade, rows slide to their new rank as you type (measured in layout pixels, so it survives scaled previews), a single spring-driven cursor follows the arrow keys and the pointer, Enter or a tap picks a row with a brief confirmation and remembers it under Recent, each score gets a small bar, and an unmatched query shows a calm empty state with a one-tap clear. It works controlled or uncontrolled, ships a dark and a light tone, and is a dependency-free single file. For a few hundred rows it is instant; past a few thousand, or when typo tolerance matters, reach for a real scorer.
Curator’s note
Rebuilt in October as a proper combobox. Returning matched indices rather than a boolean is still the one decision that unlocks everything else here.
