{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "highlight-text",
  "title": "Highlight Text",
  "description": "Text with the parts matching a search query highlighted, built so that neither the text nor the query can break it — and both of them routinely arrive from a URL query string. Reach for it wherever somebody has just typed something and needs to see where it landed: search results and their snippets, a filtered list or a table under an active filter, in-page and in-document find, a docs or knowledge-base search page, log and diff viewers, autocomplete and combobox options, a tag or user picker, admin record lookup, and the \"showing 12 results for …\" line above any of them. Common asks it answers: \"highlight search term react\", \"highlight matching text react\", \"react-highlight-words alternative\", \"highlight-words-core\", \"react highlighter component\", \"shadcn highlight text\", \"highlight search results react\", \"mark tag react\", \"wrap matches in mark react\", \"highlight substring in string react\", \"highlight multiple words react\", \"case insensitive highlight react\", \"highlight text without dangerouslySetInnerHTML\", \"highlight search term xss\", \"escape regex special characters search\", \"new RegExp from user input invalid regular expression\", \"search query with special characters breaks\", \"highlight accent insensitive search\", \"remove diacritics search javascript\", \"normalize NFD strip combining marks\", \"highlight overlapping matches\", \"find in page highlight react\", \"scroll to current match react\", \"検索ワード ハイライト react\", \"該当箇所を光らせる\", \"検索結果 マーカー 表示\", \"全角 正規表現 エスケープ 検索\". The reason to install one rather than write it is that the three-line version is a security bug. `text.replace(query, '<mark>' + query + '</mark>')` into dangerouslySetInnerHTML is what everybody writes first, and it injects HTML from two directions at once — the body text and the search term, which on a results page is usually `?q=` straight off the address bar. This never builds a string of HTML at all. It cuts the text into runs and React renders them, so a `<script>` in either input is characters on screen and nothing else. The query is never compiled to a regular expression either, which is the other half of the same problem and the one that shows up in the bug tracker rather than the security report. `C++`, `a.b`, `$100`, `(draft)` and `[WIP]` are all ordinary things to type into a search box and all of them are also regex syntax: `new RegExp(q)` throws `Invalid regular expression` on some and, worse, quietly matches the wrong text on others — search `a.b` and watch `axb` light up. The usual patch is an escaping helper copied from a gist. Here there is nothing to escape, because matching is `indexOf` over a folded copy of the string. That folded copy is where the real work is. Matching has to ignore case and accents — `café` should be found by `cafe` — but the highlight has to be drawn on the original text, and the two strings do not have the same length. Stripping a combining mark shortens it; `İ` lowercases to two characters and lengthens it; `Σ` and its word-final form `ς` are the same letter written two ways, so `ΕΛΛΑΣ` is invisible to anyone typing `ελλάς`. An offset found in one string and used in the other is wrong by a few characters, and the damage lands next to the highlight rather than in it — a duplicated or eaten letter somewhere along the line, where nobody is looking. So no offset is ever converted: the text is split into grapheme clusters with `Intl.Segmenter`, each is folded on its own, and every match is carried back through a recorded boundary. Concatenating the output always reproduces the input exactly, whatever the query. Working in graphemes is also what stops it tearing a character in half. A flag is two regional indicators, a thumbs-up with a skin tone is a base plus a modifier, and a `<mark>` drawn around the first half of either one splits it on screen — the flag falls apart into two letters, the modifier is orphaned beside the thumb. A match that covers only part of a character is passed over and the search carries on from the next position. Multiple terms are handled the way a reader expects rather than the way the loop falls out. A string query is split on whitespace, because that is what the search backend did with it and a phrase that never occurs verbatim would otherwise highlight nothing at all; pass an array to match exact phrases instead. Terms that overlap — `ab` and `bc` both land on `abc` — are merged into one highlight, since a `<mark>` cannot be nested inside another and emitting the shared letter twice corrupts the text. Matches that merely touch are deliberately left as two, so a find bar still counts what the reader can see. It is a real `<mark>` element rather than a styled span, which matters on somebody else's machine: in Windows high contrast mode the browser replaces author colours with system ones and knows to give a `<mark>` the system's own highlight pair, while a span painted to look identical is handed the ordinary page colours and every highlight on the page silently disappears for the readers who turned high contrast on in order to see things. The user-agent `color: black` that ships with `<mark>` is overridden so the text keeps the colour it already had instead of turning black in dark mode, and the highlight carries no horizontal padding, which would otherwise re-space the line as the reader types. The text itself is never cut down or rewritten, so screen readers, find-in-page, selection and copy all see exactly the string that was passed in. For a find bar with next and previous buttons, `activeIndex` marks one match as the current one: the marks carry `data-match-index`, the active one carries `data-active=\"true\"` and `aria-current`, and bringing it into view is `container.querySelector('[data-active=\"true\"]')?.scrollIntoView()`. `splitHighlight(text, query, options)` is exported on its own — a pure function from a string and a query to a list of runs — for counting matches, for highlighting into a canvas or a PDF, or for testing your own rendering. Official shadcn/ui has nothing for this, and the measurement is not close: fetching all sixty-three registry entries today (sixty-two are fetchable, questionnaire alone 404s, and nine of the newer ones are served only on the new-york-v4 style track) and concatenating the component sources gives 211,787 bytes, in which `<mark`, `Highlight`, `escapeRegExp`, `Intl.Segmenter`, `normalize(\"NFD\")`, `searchWords`, `caseSensitive` and `matchDiacritics` are every one of them zero hits. The two occurrences of the word are `data-highlighted`, the Radix menu-item state, which is a different thing entirely. Within pulld it is the display half of search: search-input is where the query is typed, command-palette highlights the fuzzy subsequence it matched inside its own option list, and this is the one you point at arbitrary body text. It composes with read-more and middle-truncate on the same line of a result, and sits naturally in a table cell, a tree-view label or a diff-view row. No dependencies, and no hooks — so it renders inside a React server component with no \"use client\" of its own and ships no client JavaScript, which is the common case, because search results have usually just been fetched on the server.",
  "files": [
    {
      "path": "registry/ui/highlight-text.tsx",
      "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface HighlightOptions {\n  /** Match upper and lower case as distinct characters. Defaults to false. */\n  caseSensitive?: boolean\n  /** Treat `é` and `e` as distinct characters. Defaults to false. */\n  matchDiacritics?: boolean\n  /**\n   * Split a string query on whitespace and match each word on its own. Defaults to true, which\n   * is what a search box's contents mean: the backend tokenised them, so the highlight should\n   * too, and a phrase that never occurs verbatim would otherwise light up nothing at all. Pass\n   * an array instead to match exact phrases — an array is always taken term by term, as given.\n   */\n  splitWords?: boolean\n}\n\n/** One run of the original text, flagged with whether the query matched it. */\nexport interface HighlightSegment {\n  text: string\n  match: boolean\n}\n\ntype GraphemeSegmenter = { segment(input: string): Iterable<{ segment: string }> }\ntype SegmenterConstructor = new (\n  locales: undefined,\n  options: { granularity: \"grapheme\" }\n) => GraphemeSegmenter\n\n/**\n * Split into user-perceived characters rather than UTF-16 code units.\n *\n * The unit of matching has to be the unit of rendering. A `<mark>` is drawn around a slice of the\n * original string, so a slice that begins or ends inside an emoji, a flag or an accented letter\n * does not merely look untidy — it tears a character in half and both halves render as replacement\n * glyphs. Working in graphemes makes that unrepresentable: every boundary this file can produce is\n * a boundary a reader would recognise.\n */\nfunction toUnits(text: string): string[] {\n  const Segmenter = (Intl as unknown as { Segmenter?: SegmenterConstructor }).Segmenter\n  if (typeof Segmenter === \"function\") {\n    return Array.from(\n      new Segmenter(undefined, { granularity: \"grapheme\" }).segment(text),\n      (entry) => entry.segment\n    )\n  }\n  // Older engines: iterating a string yields code points, which keeps surrogate pairs whole even\n  // though it still splits combining marks off their base letter. The folding below absorbs that —\n  // a lone combining mark folds to nothing and is carried along by the character before it.\n  return Array.from(text)\n}\n\n// Safe to share despite the `g` flag. `String.prototype.replace` always starts at zero and leaves\n// `lastIndex` at zero afterwards; it is `test` and `exec` that carry the index between calls and\n// make a shared global regex skip half its input.\nconst COMBINING_MARKS = /\\p{M}/gu\n\n/**\n * The comparison form of one grapheme.\n *\n * Case first, then marks, because lowercasing can *introduce* a combining mark: `İ` (U+0130) has\n * no single-character lowercase, so `toLowerCase` returns `i` followed by a combining dot. Strip\n * marks first and that dot survives the fold, leaving `İstanbul` unfindable by `istanbul`.\n *\n * `toLowerCase` and not `toLocaleLowerCase`: locale-aware casing would make the same text\n * highlight differently depending on where the reader is — in Turkish, `I` lowercases to a\n * dotless `ı`, so `ISTANBUL` would stop matching `istanbul` on exactly one set of machines.\n *\n * The sigma line is the one special case worth carrying. Greek writes its lowercase sigma as `ς`\n * at the end of a word and `σ` everywhere else; `Σ`.toLowerCase() answers `σ` with no word to\n * look at, so a reader searching for `Ελλάς` as they would write it never matches the `ΕΛΛΑΣ` on\n * screen. Folding both forms together costs one replace and removes the whole class.\n */\nfunction foldUnit(unit: string, caseSensitive: boolean, matchDiacritics: boolean): string {\n  let folded = unit\n  if (!caseSensitive) folded = folded.toLowerCase().replace(/ς/g, \"σ\")\n  if (!matchDiacritics) folded = folded.normalize(\"NFD\").replace(COMBINING_MARKS, \"\")\n  return folded\n}\n\n/** Fold a query term the same way, grapheme by grapheme, so both sides agree character for character. */\nfunction foldTerm(term: string, caseSensitive: boolean, matchDiacritics: boolean): string {\n  let folded = \"\"\n  for (const unit of toUnits(term)) folded += foldUnit(unit, caseSensitive, matchDiacritics)\n  return folded\n}\n\ninterface FoldedText {\n  /** The comparison form of the whole text — the string `indexOf` actually searches. */\n  folded: string\n  /** Where unit `i` begins in the *original* text. One longer than the unit count; last entry is its length. */\n  origin: number[]\n  /**\n   * For every index in `folded`, the unit boundary sitting there, or -1 for a position inside a\n   * character. This is what keeps folding from corrupting the output: the two strings have\n   * different lengths and there is no arithmetic that converts between them, so an offset found\n   * in one is only ever carried back to the other through a boundary recorded here.\n   */\n  boundary: number[]\n}\n\nfunction foldText(text: string, caseSensitive: boolean, matchDiacritics: boolean): FoldedText {\n  const units = toUnits(text)\n  const origin: number[] = []\n  const starts: number[] = []\n  let folded = \"\"\n  let at = 0\n  for (const unit of units) {\n    origin.push(at)\n    starts.push(folded.length)\n    folded += foldUnit(unit, caseSensitive, matchDiacritics)\n    at += unit.length\n  }\n  origin.push(at)\n  starts.push(folded.length)\n\n  const boundary = new Array<number>(folded.length + 1).fill(-1)\n  // Ascending, so where several units share a folded position the *last* one wins. That is what\n  // decides which side of a highlight a zero-width character lands on, and both answers it gives\n  // are the ones a reader would want: a combining mark that folds away is kept with the letter it\n  // belongs to (its own start equals the following boundary, so it is swallowed by a match that\n  // ends there), while a zero-width joiner sitting before a match stays outside it.\n  for (let i = 0; i < starts.length; i++) boundary[starts[i]] = i\n  return { folded, origin, boundary }\n}\n\n/** Every non-overlapping occurrence of one already-folded term, as `[firstUnit, lastUnit)` pairs. */\nfunction unitRanges(text: FoldedText, term: string): Array<[number, number]> {\n  const ranges: Array<[number, number]> = []\n  // An empty term would make `indexOf` answer the search position forever. The guard is here\n  // rather than only at the call site because this is the loop that would not terminate.\n  if (term.length === 0) return ranges\n\n  let from = 0\n  while (from + term.length <= text.folded.length) {\n    const start = text.folded.indexOf(term, from)\n    if (start === -1) break\n    const end = start + term.length\n    const first = text.boundary[start]\n    const last = text.boundary[end]\n    if (first !== -1 && last !== -1) {\n      ranges.push([first, last])\n      from = end\n    } else {\n      // The match landed inside a character — one half of a `ß` folded to `ss`, say. There is no\n      // honest way to draw that, so it is passed over and the search goes on from the next\n      // position rather than stopping.\n      from = start + 1\n    }\n  }\n  return ranges\n}\n\n/**\n * Flatten overlapping ranges into one each, leaving touching ones alone.\n *\n * Two terms genuinely can overlap — `ab` and `bc` both hit `abc` — and rendering that as written\n * would either nest one `<mark>` inside another or emit the shared letter twice, so overlaps have\n * to go. Ranges that merely touch are left as they are, which is the less obvious half: `abab`\n * searched for `ab` is two matches, and folding them into one highlight would make a find bar\n * count one where the reader can see two, so `activeIndex` would run out of places to go. They\n * sit flush against each other and read as a single run either way — which is why the mark below\n * carries no horizontal padding, since padding is what would push the two apart and put a visible\n * gap in the middle of a word.\n */\nfunction mergeRanges(ranges: Array<[number, number]>): Array<[number, number]> {\n  if (ranges.length < 2) return ranges\n  const sorted = [...ranges].sort((a, b) => a[0] - b[0] || a[1] - b[1])\n  const merged: Array<[number, number]> = []\n  for (const range of sorted) {\n    const last = merged[merged.length - 1]\n    if (last && range[0] < last[1]) {\n      if (range[1] > last[1]) last[1] = range[1]\n    } else {\n      merged.push([range[0], range[1]])\n    }\n  }\n  return merged\n}\n\nfunction toTerms(query: string | string[], splitWords: boolean): string[] {\n  const raw = Array.isArray(query) ? query : splitWords ? query.split(/\\s+/) : [query]\n  const terms: string[] = []\n  for (const term of raw) {\n    // Trimmed because a trailing space is what a half-typed query looks like, and a term that is\n    // only whitespace would otherwise highlight every gap between words.\n    if (typeof term === \"string\" && term.trim().length > 0) terms.push(term.trim())\n  }\n  return terms\n}\n\n/**\n * Cut `text` into alternating matched and unmatched runs.\n *\n * The whole component is this function plus a `<mark>`, and it is separate so that the matching\n * can be tested exhaustively without a DOM, and reused wherever the rendering is not React —\n * canvas, PDF, a terminal, a different framework.\n *\n * Concatenating the segments always reproduces `text` exactly, whatever the query. That is the\n * invariant worth holding onto: it is what makes the accent folding safe (the text is searched in\n * one form and sliced in another) and it is why nothing here can lose or duplicate a character.\n */\nexport function splitHighlight(\n  text: string,\n  query: string | string[],\n  options: HighlightOptions = {}\n): HighlightSegment[] {\n  const { caseSensitive = false, matchDiacritics = false, splitWords = true } = options\n  if (!text) return []\n\n  const terms = toTerms(query, splitWords)\n  if (terms.length === 0) return [{ text, match: false }]\n\n  const folded = foldText(text, caseSensitive, matchDiacritics)\n  const ranges: Array<[number, number]> = []\n  for (const term of terms) {\n    ranges.push(...unitRanges(folded, foldTerm(term, caseSensitive, matchDiacritics)))\n  }\n\n  const merged = mergeRanges(ranges)\n  if (merged.length === 0) return [{ text, match: false }]\n\n  const segments: HighlightSegment[] = []\n  let cursor = 0\n  for (const [first, last] of merged) {\n    const start = folded.origin[first]\n    const end = folded.origin[last]\n    if (start > cursor) segments.push({ text: text.slice(cursor, start), match: false })\n    segments.push({ text: text.slice(start, end), match: true })\n    cursor = end\n  }\n  if (cursor < text.length) segments.push({ text: text.slice(cursor), match: false })\n  return segments\n}\n\nexport interface HighlightTextProps\n  extends Omit<React.ComponentPropsWithoutRef<\"span\">, \"children\">,\n    HighlightOptions {\n  /** The text to display. Rendered in full — matching never removes or rewrites any of it. */\n  text: string\n  /** What to look for. A string is the query as typed; an array is a list of exact phrases. */\n  query: string | string[]\n  /**\n   * Which match to mark as the current one, for a find bar with next/previous buttons. The marks\n   * carry `data-match-index`, and the active one carries `data-active=\"true\"`, so it can be\n   * brought into view with `container.querySelector('[data-active=\"true\"]')?.scrollIntoView()`.\n   */\n  activeIndex?: number\n  /** Classes for the `<mark>` elements. */\n  markClassName?: string\n}\n\n/**\n * Text with the parts matching a search query highlighted.\n *\n * Use it in search results and filtered lists, in-page and in-document search, log and diff\n * viewers, command palette and autocomplete options, table cells under an active filter, and\n * anywhere a reader has just typed something and needs to see where it landed.\n *\n * The text is rendered whole and only visually marked, so screen readers, find-in-page, copy and\n * text selection all see exactly the string that was passed in.\n */\nexport const HighlightText = React.forwardRef<HTMLSpanElement, HighlightTextProps>(\n  function HighlightText(\n    {\n      text,\n      query,\n      caseSensitive = false,\n      matchDiacritics = false,\n      splitWords = true,\n      activeIndex,\n      markClassName,\n      className,\n      ...props\n    },\n    ref\n  ) {\n    const segments = splitHighlight(text, query, { caseSensitive, matchDiacritics, splitWords })\n\n    let matchIndex = -1\n    return (\n      <span ref={ref} className={className} {...props}>\n        {segments.map((segment, index) => {\n          if (!segment.match) return <React.Fragment key={index}>{segment.text}</React.Fragment>\n          matchIndex += 1\n          return (\n            <mark\n              key={index}\n              data-match-index={matchIndex}\n              data-active={matchIndex === activeIndex ? \"true\" : undefined}\n              // A real `<mark>` rather than a styled `<span>`, for a reason that only shows up on\n              // someone else's machine: in Windows high contrast mode the browser replaces author\n              // colours with system ones, and it knows to give a `<mark>` the system's own\n              // highlight pair. A span painted with the same classes is handed the ordinary page\n              // colours instead, and every highlight on the page silently disappears for the\n              // readers who turned high contrast on in order to see things.\n              //\n              // `text-inherit` undoes the user-agent `color: black`, which would otherwise render\n              // black text in dark mode and override the muted colour of whatever line this sits\n              // in. Only the background changes; the text keeps the colour it already had.\n              className={cn(\n                \"rounded-[0.2em] bg-primary/20 text-inherit\",\n                \"data-[active=true]:bg-primary/40 data-[active=true]:ring-1 data-[active=true]:ring-primary/50\",\n                markClassName\n              )}\n              aria-current={matchIndex === activeIndex ? \"true\" : undefined}\n            >\n              {segment.text}\n            </mark>\n          )\n        })}\n      </span>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
