Text

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

export type SearchHighlightMode = "substring" | "words" | "fuzzy"

export interface SearchHighlightMatch {
  /** The matched slice of the original text. */
  text: string
  /** 0-based position of this match among all rendered matches. */
  index: number
  /** Start offset inside the original text. */
  start: number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/search-highlight.json

Prompt

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 text string 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 cursoractiveIndex + onMatchCount are the two halves of a find-in-page bar: the count feeds the 3 / 12 readout, the index picks which <mark> gets the inverted treatment and aria-current.

On This Page