Skip to content

Fuzzy Match Input

Subsequence search that scores by run length and word starts, and highlights the hits.

Freeminimalbrutalisteditorial
Category
Components
Added
2026-09-20
Deps
1
Updated
2026-09-20
Preview
  • Command Menu9

Props

5
Show score
Requires lucide-react
"use client";

import { useMemo, useState } 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[qi];
    if (ch === " ") continue;

    let found = -1;
    while (ti < t.length) {
      if (t[ti] === ch) {
        found = ti;
        break;
      }
      ti++;
    }
    if (found === -1) return null; // a query char is missing -> no match

    indices.push(found);
    // Consecutive hits are worth far more than scattered ones, so "cmd"
    // prefers a contiguous run over three letters spread across the string.
    run = indices.length > 1 && found === indices[indices.length - 2] + 1 ? run + 1 : 0;
    score += 1 + run * 4;
    // Word starts are a strong signal of intent.
    if (found === 0 || t[found - 1] === " " || t[found - 1] === "-") score += 6;
    ti = found + 1;
  }

  return { indices, score };
}

export function FuzzyMatchInput({
  rows,
  limit = 5,
  placeholder = "Search…",
  showScore = true,
}: {
  rows: string[];
  limit?: number;
  placeholder?: string;
  /** Print each row's match score, which makes the ranking legible. */
  showScore?: boolean;
}) {
  const [query, setQuery] = useState("");

  const results = useMemo(
    () =>
      rows
        .map((row) => ({ row, match: fuzzyMatch(query, row) }))
        .filter((r): r is { row: string; match: NonNullable<Match> } => r.match !== null)
        .sort((a, b) => b.match.score - a.match.score)
        .slice(0, limit),
    [rows, query, limit]
  );

  return (
    <div className="overflow-hidden rounded-lg border border-white/15 bg-neutral-950">
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder={placeholder}
        aria-label="Search"
        className="w-full border-b border-white/10 bg-transparent px-3 py-2.5 text-[13px] text-white outline-none placeholder:text-white/40"
      />

      <ul className="max-h-60 overflow-y-auto py-1">
        {results.map(({ row, match }) => (
          <li
            key={row}
            className="flex items-baseline justify-between gap-3 px-3 py-1.5 text-[13px] text-white/60"
          >
            <span>
              {row.split("").map((ch, i) => (
                <span
                  key={i}
                  className={match.indices.includes(i) ? "font-semibold text-blue-400" : undefined}
                >
                  {ch}
                </span>
              ))}
            </span>
            {showScore && (
              <span className="shrink-0 font-mono text-[10px] text-white/40">{match.score}</span>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}
Notes

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 — the highlight is then just a per-character render against that index set, and it is the decision the rest of the component depends on. What separates a usable fuzzy search from an annoying one is the scoring, because subsequence matching alone is far too permissive. Two bonuses do most of the work: consecutive characters score progressively higher than scattered ones, so a contiguous run outranks three letters spread across a sentence, and matches at a word start earn a flat bonus, which is what makes initialisms behave the way people expect. This is deliberately a dependency-free fifty-line matcher rather than a library — for a few hundred rows it is instant, and it can be tuned. Past a few thousand, or with typo tolerance required, reach for a real scorer.

Curator’s note

Built for viberdy 2.0. Returning matched indices rather than a boolean is the one decision that unlocks everything else here.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

A search icon that smoothly expands into a full text input on click.

searchinputexpand

A filterable FAQ list where each answer expands independently.

faqsearchaccordion

A text input whose label floats from placeholder position into a caption on focus.

inputformlabel