javascript
39 lines · 7 steps
Safely highlighting search matches in text
Wrap query terms in <mark> tags while HTML-escaping everything, so highlighting never introduces an injection.
Explained by
highlit
1function escapeHtml(str) {
2 return str.replace(/[&<>"']/g, (ch) => ({
3 '&': '&',
4 '<': '<',
5 '>': '>',
6 '"': '"',
7 "'": ''',
8 }[ch]));
9}
10
11function escapeRegExp(str) {
12 return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
13}
14
15export function highlightMatches(text, query) {
16 const terms = query
17 .trim()
18 .split(/\s+/)
19 .filter(Boolean)
20 .map(escapeRegExp);
21
22 if (terms.length === 0) {
23 return escapeHtml(text);
24 }
25
26 const pattern = new RegExp(`(${terms.join('|')})`, 'gi');
27 let result = '';
28 let lastIndex = 0;
29
30 for (const match of text.matchAll(pattern)) {
31 const start = match.index;
32 result += escapeHtml(text.slice(lastIndex, start));
33 result += `<mark>${escapeHtml(match[0])}</mark>`;
34 lastIndex = start + match[0].length;
35 }
36
37 result += escapeHtml(text.slice(lastIndex));
38 return result;
39}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Escape user-controlled text before building HTML to avoid injecting markup.
- 2Escape query terms for regex so special characters are matched literally, not interpreted.
- 3Interleaving escaped plain text with escaped matches lets you inject safe markup at precise boundaries.
Related explainers
javascript
import { useState, useRef } from "react"; export function TagInput({ initialTags = [], onChange }) { const [tags, setTags] = useState(initialTags);
Building a tag input in React
controlled-inputs
state-management
keyboard-handling
Intermediate
8 steps
javascript
import { NavLink, useLocation } from 'react-router-dom'; const NAV_ITEMS = [ { to: '/', label: 'Dashboard', end: true },
Building an accessible Sidebar in React
routing
accessibility
declarative-ui
Intermediate
6 steps
javascript
import { useEffect, useRef } from "react"; import { useBlocker } from "react-router-dom"; export function useUnsavedChangesPrompt(isDirty, message = "You have unsaved changes. Leave anyway?") {
Guarding unsaved changes with a React hook
custom-hooks
navigation-guard
event-listeners
Intermediate
7 steps
javascript
function deepFreeze(obj) { const propNames = Reflect.ownKeys(obj); for (const name of propNames) {
Recursively freezing a nested object
recursion
immutability
object-freezing
Intermediate
6 steps
javascript
class InfiniteScroll { constructor(sentinel, { loadMore, root = null, rootMargin = '200px' } = {}) { this.sentinel = sentinel; this.loadMore = loadMore;
Infinite scroll with IntersectionObserver
intersectionobserver
pagination
async
Intermediate
10 steps
php
final class Route { private string $regex; private array $paramNames = [];
Compiling route patterns into regex in PHP
routing
regex
parsing
Intermediate
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/safely-highlighting-search-matches-in-text-explained-javascript-3c6c/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.