Search Highlight
Marks the parts of a text that match a search query — substring, per-word or fuzzy, with escaped queries and a current-match cursor.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/search-highlight.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SearchHighlight" component (React only —
no matching or highlighting library, no dangerouslySetInnerHTML).
Contract
- Export a forwardRef span extending React.HTMLAttributes<HTMLSpanElement> with
"children" omitted: the component owns its children, they are produced from `text`.
- Props: text (string), query (string | string[]),
mode = "substring" | "words" | "fuzzy" (default "substring"),
caseSensitive (default false), wholeWord (default false),
maxMatches (number, optional cap), highlightClassName, activeClassName,
activeIndex (number, optional), onMatchCount((count: number) => void),
renderMatch((match) => ReactNode).
- renderMatch receives { text, index, start, end, active } and REPLACES the whole
mark element — the consumer then owns the semantics too.
- Also export the pure matcher findMatches(text, query, options) => { start, end }[]
so a list can filter or count rows without mounting a component per row.
- An array query is always an explicit term list; a string is split (or not)
according to `mode`.
Behavior
- Terms: substring trims the query and keeps it as ONE literal phrase (so "dark
mode" only matches that exact phrase); words splits on whitespace into
independent terms; fuzzy strips whitespace and treats the query as a character
subsequence. Deduplicate terms and sort them LONGEST FIRST — regex alternation
takes the first branch that matches at a position, so without that "dark" would
win over "dark mode" at the same index.
- Regex safety (the whole point of this component): the query is user input, not a
pattern. Escape [.*+?^${}()|[\]\\] in every term before it reaches RegExp,
otherwise searching for "a.b" also matches "axb" and searching for "(" throws.
Keep the RegExp construction in a try/catch anyway and degrade to "no matches".
- One scan, not one scan per term: join all escaped terms into a single
alternation and exec it once over the text (flags "g" or "gi"; no "u" flag, so
offsets stay code-unit based and line up with String.slice).
- After every hit set lastIndex to hit.start + 1, never to hit.end: two terms can
overlap ("abc" + "bcd" inside "abcd") and resuming past the hit would silently
drop the second one.
- wholeWord: check the neighbours manually instead of \b, and only on the edges
where the term itself starts/ends with a word character (Unicode letter, number
or underscore) — otherwise a punctuation query like "(" could never match. A hit
rejected by the boundary check must not stop the scan.
- Merge ranges that overlap OR touch into one range. Two adjacent mark elements
are announced as two separate highlights by screen readers, and "darkdark" for
the query "dark" is one highlighted run, not two.
- fuzzy: walk the text once, consuming query characters in order; every hit
character becomes a 1-char range and consecutive hits merge into a run. If the
query is not a subsequence of the text, return NO matches instead of a
misleading partial run. wholeWord does not apply.
- Degenerate input never throws and never mangles the text: empty text, empty or
whitespace-only query, a query longer than the text, and an all-empty array all
render the original text unchanged with zero marks.
- maxMatches is clamped: floor it, treat negatives as 0, ignore NaN/Infinity, and
apply it AFTER merging so the number of rendered marks is exactly the cap.
- Memoize the match list on [text, serialized query, mode, caseSensitive,
wholeWord, maxMatches]. Serialize an array query by joining it with a NUL
delimiter and splitting it back inside the memo: an inline query={["a","b"]}
literal is a new array on every render and must not re-scan a 50k-char
document, and NUL (unlike a space) cannot occur inside a typed term, so a
multi-word phrase term survives the round trip.
- onMatchCount is called from an effect that depends on the count only, with the
callback kept in a latest-ref — a consumer's inline arrow function must not
re-trigger it, and it must never fire during render.
- activeIndex marks one hit as the "current" one (browser find-in-page cursor):
stronger styling plus aria-current="true". Out-of-range or undefined simply
means no current match; wrapping/clamping belongs to the consumer (cursor
modulo count is the one-liner).
Rendering & styling
- Every hit is a real <mark> element: browsers, find-in-page and screen readers
all understand it. Between hits, plain text nodes — nothing else is wrapped.
- <mark>'s user-agent background is a fixed yellow that ignores the color scheme,
so it MUST be overridden: bg-primary/30 + text-foreground for a normal hit,
bg-primary + text-primary-foreground for the active one (measured on the
default theme: 10.1:1 light / 8.2:1 dark for normal, 17.2:1 / 15.7:1 for active,
and the band itself is ~2:1 against the page so it stays visible).
- box-decoration-clone so a hit that wraps across lines gets the band on both
lines; px-0.5 with -mx-0.5 gives the band visual padding without shifting the
surrounding text as the query changes.
- Merge classes with cn() in the order base -> highlightClassName -> active
classes, so the consumer can restyle every mark while the active state still
wins over it.
- There is no animation anywhere: the highlight is information, not motion, so
there is nothing to gate on prefers-reduced-motion and nothing to clean up.
Customization levers
- Band strength: bg-primary/30 is the knob. Raise it for a louder highlight, but
re-check text contrast in BOTH schemes; a translucent tint is what keeps the
text readable when the theme flips.
- Palette: swap primary for accent/secondary, or pass highlightClassName per
surface (muted rows vs. headings). One color per meaning — do not paint two
different concepts with two different tints on the same screen.
- Active treatment: full inversion reads like Ctrl+F; a ring (ring-2 ring-ring)
reads quieter if you already invert something else nearby.
- renderMatch: turn hits into links, tooltips or buttons ("jump to line") — you
then own the element, so keep <mark> inside it if you still want the semantics.
- Mode as a product decision: substring for exact phrase search, words for a
typical multi-keyword search box, fuzzy for command palettes and file pickers.
- Cost control on huge documents: cap with maxMatches, and/or highlight a snippet
around the first hit instead of the whole document — the matcher is linear, but
thousands of DOM nodes are not free.Concepts
- Query-driven, not decorative — the highlight is a function of what the user typed; nothing is highlighted until a query produces offsets, and everything is derived from the same
textstring that would have been rendered anyway. - Escaping is the correctness story — a search box hands you
a.b,(or*; unescaped they become metacharacters that either match the wrong thing or throw. Escaping every term is what makes "search" and "regex search" two different features. - One alternation, one pass — all terms go into a single regex so a 50k-character document is scanned once instead of once per word, and the result is memoized on a serialized query key so an inline array prop can't turn typing into re-scanning.
- Merged runs — overlapping and touching hits collapse into one range, because two adjacent
<mark>elements are two announcements to a screen reader and two visual boxes where a human sees one word. - Subsequence matching — fuzzy mode is the command-palette model: the query's characters must appear in order, each hit character is marked, and a query that isn't a subsequence highlights nothing rather than showing a half-truth.
- Current-match cursor —
activeIndex+onMatchCountare the two halves of a find-in-page bar: the count feeds the3 / 12readout, the index picks which<mark>gets the inverted treatment andaria-current.
Glitch Text
Cyberpunk glitch headline — two pseudo-element copies tear the text into slices and slide, in pure CSS, dead still under reduced motion.
Fit Text
One line of text scaled until it exactly fills the width it was given — headline, KPI number or cover title, sized from a real browser measurement instead of an estimate.