Modern, zero-dependency component for highlighting text using CSS Custom Highlight API
- Vanilla JS Demo - Pure JavaScript implementation (no framework)
- React Storybook - Interactive examples with all features
- Codesandbox demo - basic use cases
- Blazing Fast - No DOM mutiations! Uses
TreeWalkerfor efficient DOM traversal (500× faster than naive approaches) - Non-Invasive - Zero impact on your DOM structure or React component tree. The DOM is not mutated.
- Non-Blocking - Uses
requestIdleCallbackto prevent UI freezes during search operations - Fully Customizable - Control highlights colors with simple CSS variables
- Multi-Term Support - Highlight multiple search terms simultaneously with different styles
- Positional compare (markup-agnostic) —
createCompareHighlightdiffs flattened text (every#textnode under a root, in tree order). How you wrap words in elements does not change the compared string—only the actual characters do. Mismatches paint via the CSS Highlight API (Ranges on those text nodes,::highlight()styles), not by injecting<mark>or forcing both sides to share the same HTML shape (vanilla / framework-agnostic) - Zero Dependencies - Pure React + Modern Browser APIs
- Multiple Usage Patterns - React (ref-based/wrapper/hook) or vanilla JS (framework-agnostic)
- TypeScript First - Full type safety with extensive JSDoc documentation
- Framework Agnostic - Use with React, Vue, Svelte, Angular, or vanilla JavaScript
- Clean Architecture - React hook is a thin wrapper around framework-agnostic core
- Installation
- Quick Start
- Usage Patterns
- API Reference
- Styling
- Performance
- Browser Support
- Advanced Examples
- Best Practices
- Troubleshooting
- Contributing
Install via npm:
npm install react-css-highlightOr using pnpm:
pnpm add react-css-highlightOr using yarn:
yarn add react-css-highlightimport { useRef } from "react";
import Highlight from "react-css-highlight";
function SearchResults() {
const contentRef = useRef<HTMLDivElement>(null);
return (
<>
<Highlight search="React" targetRef={contentRef} />
<div ref={contentRef}>
<p>React is a JavaScript library for building user interfaces.</p>
<p>React makes it painless to create interactive UIs.</p>
</div>
</>
);
}Result: All instances of "React" will be highlighted with a yellow background.
This library can be used in React or vanilla JavaScript (Vue, Svelte, Angular, etc.).
There are three ways to use this library in React, each suited for different scenarios:
Use when:
- Multiple highlights on the same content
- Working with portals or complex layouts
- Need to highlight existing components
- Want zero performance overhead
import { useRef } from "react";
import Highlight from "react-css-highlight";
function AdvancedSearch() {
const contentRef = useRef<HTMLDivElement>(null);
return (
<>
{/* Multiple highlights with different styles */}
<Highlight
search="error"
targetRef={contentRef}
highlightName="highlight-error"
/>
<Highlight
search="warning"
targetRef={contentRef}
highlightName="highlight-warning"
/>
<div ref={contentRef}>
<p>Error: Connection failed</p>
<p>Warning: High memory usage</p>
</div>
</>
);
}Use when:
- Simple, single highlight needed
- Content is self-contained
- Want cleaner, simpler code
⚠️ Important: The child element must be a single React element that accepts arefprop. DOM elements (div, section, article, etc.) and most React components support this natively.
import { HighlightWrapper } from "@/components/general/Highlight";
function SimpleSearch() {
return (
<HighlightWrapper search="important">
<div>
<p>This is an important message about important topics.</p>
</div>
</HighlightWrapper>
);
}Valid children:
// ✅ DOM elements with ref support
<HighlightWrapper search="term"><div>Content</div></HighlightWrapper>
<HighlightWrapper search="term"><article>Content</article></HighlightWrapper>
<HighlightWrapper search="term"><section>Content</section></HighlightWrapper>
// ✅ Custom components with forwardRef (or ref prop in React 19)
const MyComponent = forwardRef((props, ref) => <div ref={ref} {...props} />);
<HighlightWrapper search="term"><MyComponent>Content</MyComponent></HighlightWrapper>
// ❌ Multiple elements
<HighlightWrapper search="term">
<div>First</div>
<div>Second</div> {/* Error: must be single element */}
</HighlightWrapper>
// ❌ Non-element children
<HighlightWrapper search="term">
Just plain text {/* Error: not a React element */}
</HighlightWrapper>When these requirements aren't met, use the Component (Ref-Based) pattern instead.
Use when:
- Building custom components or abstractions
- Need direct access to match count, error state, or browser support
- Want to control the entire render logic
- Integrating with complex state management
The useHighlight hook provides the same functionality as the Highlight component, but gives you direct access to the highlight state.
⚠️ Important: When using the hook directly, you must import the CSS file somewhere in your project (typically in your main entry file or root component):import "react-css-highlight/dist/Highlight.css";This only needs to be imported once per project, not in every file that uses the hook.
import { useRef } from "react";
import { useHighlight } from "react-css-highlight";
// Note: CSS should be imported once in your app's entry point, not here
function CustomHighlightComponent() {
const contentRef = useRef<HTMLDivElement>(null);
const { matchCount, isSupported, error } = useHighlight({
search: "React",
targetRef: contentRef,
highlightName: "highlight",
caseSensitive: false,
wholeWord: false,
maxHighlights: 1000,
debounce: 100,
onHighlightChange: (count) => console.log(`Found ${count} matches`),
onError: (err) => console.error("Highlight error:", err),
});
return (
<div>
{!isSupported && (
<div className="warning">
Your browser doesn't support CSS Custom Highlight API
</div>
)}
{error && (
<div className="error">
Error: {error.message}
</div>
)}
<div className="match-count">
Found {matchCount} matches
</div>
<div ref={contentRef}>
<p>React is a JavaScript library for building user interfaces.</p>
<p>React makes it painless to create interactive UIs.</p>
</div>
</div>
);
}Hook Return Value:
| Property | Type | Description |
|---|---|---|
matchCount |
number |
Number of highlighted matches found |
isSupported |
boolean |
Whether the browser supports CSS Custom Highlight API |
error |
Error | null |
Error object if highlighting failed, null otherwise |
refresh |
`(search?: string | string[]) => void` |
When to use the hook vs component:
- Use the component when you just need highlighting without additional UI logic
- Use the hook when you need to:
- Display match counts in your UI
- Show error messages to users
- Conditionally render UI based on browser support
- Build complex components that need highlight state
- Integrate with form state or other React state management
- Manually control re-highlighting for dynamic content (virtualized lists, infinite scroll, etc.)
This library also provides a framework-agnostic API for use with Vue, Svelte, Angular, or vanilla JavaScript. Import from react-css-highlight/vanilla to use the createHighlight() function and utility APIs without React dependencies.
Requirements: This package uses ES modules and requires a bundler (Vite, Webpack, Esbuild) or module system. It does not support plain
<script>tags without a build tool.
📖 See Vanilla JS Documentation →
Compare two sides’ flattened text character-by-character at each index (UTF-16 code units, aligned with concatenated text nodes). Each side can be an HTMLElement or a plain string (expected text / reference without a rendered node). Mismatched characters and tails when lengths differ show as highlights on DOM sides only (string sides have no Range to paint). When both sides are elements, default names are highlight-diff-base (reference) and highlight-diff-compare (modified). No DOM markup is injected; styling uses the same CSS Custom Highlight API as search highlights.
Why CSS highlights fit compare mode: Decoration is orthogonal to your component markup. Whether the live content is one text node or split across many spans, links, or design-system wrappers, the walk still yields one logical string and the same positional diff; the engine then clips highlights to the underlying #text ranges. You avoid wrapping every differing character in new elements (and the reflow/reconciliation issues that brings in React), and you never need to “normalize” both trees to identical tag structure just to show a diff visual.
Import once: include styles so ::highlight(highlight-diff-base) / ::highlight(highlight-diff-compare) apply (for example import "react-css-highlight/dist/Highlight.css" or import "react-css-highlight/styles").
import { createCompareHighlight } from "react-css-highlight/vanilla";
import "react-css-highlight/styles";
const baseEl = document.getElementById("base");
const compareEl = document.getElementById("compare");
if (!baseEl || !compareEl) {
throw new Error("Missing #base or #compare element");
}
const ctrl = createCompareHighlight(baseEl, compareEl, {
onDiffChange(count) {
console.log("Differing character positions:", count);
},
});
// After edits (e.g. input on contenteditable), re-run comparison:
ctrl.refresh();
// Later:
ctrl.destroy();Compare a fixed reference string to a live element (highlights only the element):
const expected = "Hello, world";
const liveEl = document.getElementById("live");
if (!liveEl) throw new Error("Missing #live");
const ctrl = createCompareHighlight(expected, liveEl, {
onDiffChange(count) {
console.log("Differing character positions:", count);
},
});
liveEl.addEventListener("input", () => ctrl.refresh());Behavior notes:
- Positional by default, not Myers/LCS alignment: inserting text in one side shifts subsequent indices, so downstream regions may appear as entirely different unless strings stay the same length and prefix-identical where you care about alignment. Pass a custom
diffoption to switch to edit-script alignment. - Whitespace counts: flattened text includes all text nodes (including whitespace-only) so offsets stay stable.
- String-side gotchas: browser
textContentnormalizes line endings to\n. A Windows-style reference string like"foo\r\nbar"will show a positional diff for every\rwhen compared to equivalent DOM text — strip\rfrom your reference first.<br>elements contribute no characters totextContent; if your reference string uses\nwhere the DOM uses<br>, every newline is a diff — align formatting (e.g. match line breaks) or strip newlines from the string. - Timing: Diff count updates synchronously; painting registers with
CSS.highlightsinsiderequestIdleCallbackwhen at least one side is a DOM tree (same pattern ascreateHighlight). If both sides are strings, only the diff runs — no highlight registration is scheduled.
Live example: Vanilla JS Demo includes an interactive string-comparison block.
The useHighlight hook accepts the same options as the Highlight component and returns highlight state.
Note: When using the hook directly, you must import the CSS file once in your project:
// In your main.tsx, App.tsx, or _app.tsx import "react-css-highlight/dist/Highlight.css";This is not needed when using the
HighlightorHighlightWrappercomponents, as they import it automatically.
Parameters: Same as Highlight Component Props
Returns: UseHighlightResult
import { useHighlight } from "react-css-highlight";
// CSS already imported in main entry file
const { matchCount, isSupported, error } = useHighlight({
search: "term",
targetRef: contentRef,
highlightName: "highlight",
caseSensitive: false,
wholeWord: false,
maxHighlights: 1000,
debounce: 100,
onHighlightChange: (count) => {},
onError: (err) => {},
});| Property | Type | Description |
|---|---|---|
matchCount |
number |
Number of matches currently highlighted (updates synchronously) |
isSupported |
boolean |
Whether browser supports CSS Custom Highlight API |
error |
Error | null |
Error object if highlighting failed, null otherwise |
refresh |
(search?: string | string[]) => void |
Manually trigger re-highlighting. Optionally pass search term(s) to temporarily highlight different content without updating component state |
| Prop | Type | Default | Description |
|---|---|---|---|
search |
string | string[] |
required | Text to highlight (supports multiple terms). If array is passed, make sure it is memoed |
targetRef |
RefObject<HTMLElement | null> |
required | Ref to the element to search within |
highlightName |
string |
"highlight" |
CSS highlight name (use predefined styles from Highlight.css) |
caseSensitive |
boolean |
false |
Case-sensitive search |
wholeWord |
boolean |
false |
Match whole words only |
maxHighlights |
number |
1000 |
Maximum highlights (performance limit) |
debounce |
number |
100 |
Debounce delay in ms before updating highlights |
ignoredTags |
string[] |
undefeind |
HTML tags names whose text content should not be highlighted. These are merged with the default list of contentless ignored tags which is defined within the constants file |
onHighlightChange |
(count: number) => void |
undefined |
Callback when highlights update |
onError |
(error: Error) => void |
undefined |
Error handler |
All Highlight props except targetRef, plus:
| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode |
required | Single React element that accepts a ref prop |
import {
DEFAULT_MAX_HIGHLIGHTS, // 1000
IGNORED_TAG_NAMES, // ["SCRIPT", "STYLE", "NOSCRIPT", "IFRAME", "TEXTAREA"]
SLOW_SEARCH_THRESHOLD_MS // 100
} from "react-css-highlight";import {
useHighlight, // Main highlight hook
useDebounce // Utility debounce hook
} from "react-css-highlight";Framework-agnostic API for positional diff highlighting. Each argument is HTMLElement | string: use a string when you don’t have a rendered reference tree (only DOM sides paint). Also exported from "react-css-highlight" root for reuse in bundlers alongside React.
Signature:
createCompareHighlight(
base: HTMLElement | string,
compare: HTMLElement | string,
options?: CompareOptions
): CompareControllerTypes: CompareInput = HTMLElement | string; CompareSource — resolved { kind: 'element'; element } | { kind: 'text'; text }; read controller.sources (same object reference across reads).
| Option | Type | Default | Description |
|---|---|---|---|
baseHighlightName |
string |
"highlight-diff-base" |
::highlight() name for mismatches mapped into the base side (ignored when base is a string) |
compareHighlightName |
string |
"highlight-diff-compare" |
::highlight() name for mismatches mapped into the compare side (ignored when compare is a string) |
ignoredTags |
string[] |
(none extra) | Merged with built-in ignored tags; text under these parent tag names is skipped when flattening element sides only |
onDiffChange |
(diffCount: number) => void |
— | Runs after each compare. Default unit = differing character positions (not contiguous ranges). With a custom diff, the unit is whatever your DiffFn returns. |
onError |
(error: Error) => void |
— | Error handler |
diff |
DiffFn |
positionalDiffFn |
Pluggable diff. Override to use edit-script algorithms (LCS / Myers / Patience / Histogram) at any granularity. See Custom diff algorithms. |
CompareController
| Property / method | Type | Description |
|---|---|---|
diffCount |
number |
Differing character positions from the last synchronous compare |
sources |
{ base: CompareSource; compare: CompareSource } |
Frozen view of the two sides; narrow on kind for HTMLElement vs string |
update |
(options: Partial<CompareOptions>) => void |
Merge new options and re-run comparison |
refresh |
() => void |
Re-run comparison with current DOM text (e.g. after contenteditable changes) |
destroy |
() => void |
Clear highlights and cancel pending work |
If the browser does not support the CSS Custom Highlight API, createCompareHighlight returns a no-op controller and invokes onError when supplied. base and compare must be defined (not null / undefined); each must be a string or a valid HTMLElement (non-element values throw INVALID_INPUT). To change which element or string is compared after construction, destroy() the controller and create a new one.
The default positional diff is great for typing-tutor style "did the user type the same characters?" UX, but breaks down for prose / code where a single insertion shifts every downstream index. Pass a custom diff function to swap in any edit-script algorithm at any granularity (char / word / line / token).
Signatures:
import type { DiffFn, CustomDiffResult, DiffRange } from "react-css-highlight";
type DiffRange = { start: number; end: number }; // half-open [start, end)
interface CustomDiffResult {
baseRanges: DiffRange[]; // flat-text offsets into baseText (deletions live here)
compareRanges: DiffRange[]; // flat-text offsets into compareText (insertions live here)
diffCount: number; // opaque to controller — emitted via onDiffChange
}
type DiffFn = (baseText: string, compareText: string) => CustomDiffResult;Contract:
- Synchronous only. Throwing routes through
onErrorand clears highlights. - Out-of-bound ranges are clipped to each side's flat-text length by the range mapper, so off-by-one bugs degrade gracefully instead of crashing.
- Equal substrings should land in neither side's ranges. Edit-script algorithms naturally produce this:
equalops are skipped,deleteops add tobaseRanges,insertops add tocompareRanges,replaceops add to both. diffCountis whatever you say it is — pick a unit that means something to your callers (positions, edit ops, hunks, words changed, …) and document it.
Example: word-level diff via fast-diff:
import { createCompareHighlight, type DiffFn } from "react-css-highlight/vanilla";
import diff from "fast-diff";
import "react-css-highlight/styles";
const wordDiff: DiffFn = (baseText, compareText) => {
const ops = diff(baseText, compareText); // [op, str][] where op ∈ {-1, 0, 1}
const baseRanges = [];
const compareRanges = [];
let bi = 0;
let ci = 0;
let editOps = 0;
for (const [op, str] of ops) {
const len = str.length;
if (op === diff.EQUAL) {
bi += len;
ci += len;
} else if (op === diff.DELETE) {
baseRanges.push({ start: bi, end: bi + len });
bi += len;
editOps++;
} else {
compareRanges.push({ start: ci, end: ci + len });
ci += len;
editOps++;
}
}
return { baseRanges, compareRanges, diffCount: editOps };
};
const ctrl = createCompareHighlight(baseEl, compareEl, {
diff: wordDiff,
onDiffChange(count) {
console.log("Edit operations:", count);
},
});Replacing the diff later:
import { positionalDiffFn } from "react-css-highlight/vanilla";
ctrl.update({ diff: wordDiff }); // triggers recompute
ctrl.update({ diff: positionalDiffFn }); // back to positional default
update()stripsundefinedvalues, so passingdiff: undefinedis a no-op. To revert to positional, pass the exportedpositionalDiffFnexplicitly.
Hybrid strategy — positional for short inputs, edit-script for long:
import { positionalDiffFn } from "react-css-highlight/vanilla";
const hybrid: DiffFn = (a, b) =>
Math.max(a.length, b.length) < 200 ? positionalDiffFn(a, b) : wordDiff(a, b);Granularity tip: fast-diff, diff-match-patch, etc. work at character level. To get word- or line-level alignment, tokenize first, run the algorithm on the token sequence, then rehydrate token boundaries back into character offsets when building DiffRange[]. The library doesn't ship tokenizers — bring your own (or accept char-level alignment, which is usually fine).
For custom integrations, the same functions used internally are exported from "react-css-highlight" and "react-css-highlight/vanilla":
buildTextMap(element, ignoredTags?)— flatten descendant text to a string plus per–text-node spans (includes whitespace-only nodes).positionalDiff(baseText, compareText)— pure string positional diff; returns grouped ranges anddiffCount.positionalDiffFn(baseText, compareText)— same aspositionalDiffadapted to theDiffFnshape ({ baseRanges, compareRanges, diffCount }); used as the default when no customdiffis supplied. Re-export it inside hybridDiffFns to fall back to positional on short strings.mapDiffToRanges(diffRanges, textMap)— turn flat ranges intoRange[]clipped to the map's length.
The component comes with pre-defined highlight styles that use CSS custom properties:
::highlight(highlight) {
background-color: var(--highlight-primary, #fef3c7);
color: inherit;
}All highlight colors can be customized using CSS custom properties. Override these variables in your global stylesheet or component styles:
:root {
/* Primary highlight (default) */
--highlight-primary: #fef3c7; /* Light yellow */
/* Secondary highlight */
--highlight-secondary: #cffafe; /* Sky blue */
/* Success highlight */
--highlight-success: #dcfce7; /* Light green */
/* Warning highlight */
--highlight-warning: #fde68a; /* Orange-yellow */
/* Error highlight */
--highlight-error: #ffccbc; /* Light red */
/* Active/focused highlight */
--highlight-active: #fcd34d; /* Dark yellow */
}String comparison (default theme):
:root {
--highlight-diff-base: #fecaca; /* Light red (base / reference) */
--highlight-diff-compare: #bbf7d0; /* Light green (compare / modified) */
}Example: Customize colors to match your theme:
:root {
--highlight-primary: #e0f2fe; /* Light blue */
--highlight-success: #d1fae5; /* Mint green */
--highlight-error: #fee2e2; /* Light pink */
}The component includes several pre-defined highlight styles:
// Available variants
highlightName="highlight" // Primary (default)
highlightName="highlight-primary" // Yellow (#fef3c7)
highlightName="highlight-secondary" // Sky blue (#cffafe)
highlightName="highlight-success" // Light green (#dcfce7)
highlightName="highlight-warning" // Orange-yellow (#fde68a)
highlightName="highlight-error" // Light red (#ffccbc)
highlightName="highlight-active" // Dark yellow (#fcd34d), bold textString comparison uses two highlight layers: defaults highlight-diff-base and highlight-diff-compare, set via baseHighlightName / compareHighlightName on createCompareHighlight.
Create custom highlight styles by providing a highlightName:
<Highlight
search="error"
targetRef={ref}
highlightName="my-custom-highlight"
/>::highlight(my-custom-highlight) {
background-color: #ff0000;
color: white;
text-decoration: underline wavy;
font-weight: bold;
}createCompareHighlight accepts baseHighlightName and compareHighlightName — same ::highlight() name mechanism as the search component, applied to each side independently. Three ways to change the colors, pick whichever fits your style system:
The pre-defined variants from the previous section (highlight-primary, highlight-secondary, highlight-success, highlight-warning, highlight-error, highlight-active) are valid ::highlight() names. Point each side at the variant you want — zero CSS needed because Highlight.css already ships those rules.
import { createCompareHighlight } from "react-css-highlight/vanilla";
import "react-css-highlight/styles";
createCompareHighlight(val1Ref.current, val2Ref.current, {
baseHighlightName: "highlight-primary", // yellow on the reference side
compareHighlightName: "highlight-secondary", // sky blue on the modified side
});Pass any string. You're now responsible for defining a matching ::highlight(...) rule somewhere in your stylesheet.
createCompareHighlight(val1Ref.current, val2Ref.current, {
baseHighlightName: "highlight-foo",
compareHighlightName: "highlight-bar",
});::highlight(highlight-foo) {
background-color: LightSalmon;
color: inherit;
}
::highlight(highlight-bar) {
background-color: PaleGreen;
color: inherit;
text-decoration: underline; /* ⚠️ unsupported in Firefox */
}The names don't need a
highlight-prefix —::highlight(my-diff-left)works just as well. The prefix is only a convention used by the bundled stylesheet.
Cheapest path if the only thing you want to change is the colors. Leaves baseHighlightName / compareHighlightName at their defaults (highlight-diff-base / highlight-diff-compare) and re-themes via custom properties:
:root {
--highlight-diff-base: #fde68a; /* warm yellow on the reference side */
--highlight-diff-compare: #c7d2fe; /* indigo on the modified side */
}Or replace the rules entirely:
::highlight(highlight-diff-base) {
background-color: var(--highlight-diff-base, #fecaca);
color: inherit;
}
::highlight(highlight-diff-compare) {
background-color: var(--highlight-diff-compare, #bbf7d0);
color: inherit;
}Changing names after construction is supported — the controller re-registers under the new names on the next compute cycle:
ctrl.update({
baseHighlightName: "highlight-warning",
compareHighlightName: "highlight-success",
});- Pre-compiled Regex - Patterns compiled once per search (500× faster)
- TreeWalker - Native browser API for efficient DOM traversal
- Early Exit - Stops at
maxHighlightslimit - Empty Node Skipping - Ignores whitespace-only text nodes
- requestIdleCallback - Non-blocking highlight styling to prevent UI freezes
- Sync Match Count - Match counts calculated synchronously, styling applied asynchronously
- Performance Monitoring - Dev-mode warnings for slow searches (>100ms)
The highlighting system uses a two-phase approach for optimal performance:
-
Synchronous Phase (immediate):
- Calculates match count
- Updates state
- Calls
onHighlightChangecallback - Returns immediately to keep UI responsive
-
Asynchronous Phase (deferred):
- Applies visual highlighting using CSS Custom Highlight API
- Scheduled via
requestIdleCallbackduring browser idle time - Prevents blocking user interactions
This means matchCount is always up-to-date immediately, while visual highlights appear shortly after without blocking the main thread.
// ✅ Good - Single highlight with reasonable limit
<Highlight search="term" targetRef={ref} maxHighlights={500} />
// ✅ Good - Pre-filter search terms and memo the result
const toHighlight = useMemo(() => terms.filter(t => t.length > 2), [terms])
<Highlight
search={toHighlight}
targetRef={ref}
/>
// ⚠️ Caution - Many terms on huge documents and the array is not memoed
<Highlight
search={[...100terms]}
targetRef={ref}
maxHighlights={5000} // Consider lowering
/>| Browser | Version | Status | Notes |
|---|---|---|---|
| Chrome | 105+ | ✅ Full support | |
| Chrome Android | 105+ | ✅ Full support | |
| Edge | 105+ | ✅ Full support | |
| Firefox | 140+ | Cannot use with text-decoration or text-shadow |
|
| Firefox Android | 140+ | Same limitations as desktop | |
| Safari | 17.2+ | Style ignored when combined with user-select: none (WebKit bug 278455) |
|
| Safari iOS | 17.2+ | Same limitation as desktop | |
| Opera | 91+ | ✅ Full support | |
| Opera Android | 73+ | ✅ Full support | |
| Samsung Internet | 20+ | ✅ Full support | |
| WebView Android | 105+ | ✅ Full support |
- ❌ Cannot use
text-decoration(underline, overline, line-through) - ❌ Cannot use
text-shadow - ✅ Other styling properties work (background-color, color, font-weight, etc.)
/* ❌ Won't work in Firefox */
::highlight(my-highlight) {
text-decoration: underline;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
/* ✅ Works in Firefox */
::highlight(my-highlight) {
background-color: yellow;
color: black;
font-weight: bold;
}⚠️ Highlight style is ignored when the target element hasuser-select: none- Workaround: Remove
user-select: nonefrom highlighted content
/* ❌ Highlight won't appear in Safari */
.content {
user-select: none;
}
/* ✅ Highlight works */
.content {
user-select: auto; /* or remove the property */
}The component automatically detects browser support:
import { isHighlightAPISupported } from "react-css-highlight";
if (!isHighlightAPISupported()) {
console.warn("Browser doesn't support CSS Custom Highlight API");
}In development mode, the component logs warnings when the API is unsupported.
For browsers without support, consider:
-
Feature Detection + Graceful Degradation
const isSupported = isHighlightAPISupported(); return isSupported ? ( <Highlight search="term" targetRef={ref} /> ) : ( <TraditionalMarkHighlight search="term"> {content} </TraditionalMarkHighlight> );
-
User Notification
{!isHighlightAPISupported() && ( <div className="warning"> Your browser doesn't support text highlighting. Please upgrade to Chrome 105+, Safari 17.2+, or Firefox 140+. </div> )}
When testing your implementation:
- Chrome/Edge 105+ - Test full functionality
- Safari 17.2+ - Verify no
user-select: noneconflicts - Firefox 140+ - Avoid
text-decorationandtext-shadow - Mobile Safari - Test touch interactions with highlights
- Chrome Android - Verify performance on mobile devices
// Note: Import CSS once in your app entry point (main.tsx, App.tsx, or _app.tsx):
// import "react-css-highlight/dist/Highlight.css";
import { useState, useRef } from "react";
import { useHighlight } from "react-css-highlight";
function SearchWithStats() {
const [searchTerm, setSearchTerm] = useState("");
const contentRef = useRef<HTMLDivElement>(null);
const { matchCount, isSupported, error, refresh } = useHighlight({
search: searchTerm,
targetRef: contentRef,
debounce: 300,
});
if (!isSupported) {
return (
<div className="alert">
Your browser doesn't support text highlighting.
Please upgrade to a modern browser.
</div>
);
}
return (
<div>
<div className="search-header">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
className="search-input"
/>
<div className="search-stats">
{error ? (
<span className="error">Error: {error.message}</span>
) : (
<span className="match-count">
{searchTerm && `${matchCount} ${matchCount === 1 ? 'match' : 'matches'}`}
</span>
)}
</div>
</div>
<div ref={contentRef} className="content">
{/* Your content here */}
</div>
</div>
);
}For virtualized lists or dynamically changing content, use the refresh() callback:
import { useEffect, useRef, useState } from "react";
import { useHighlight } from "react-css-highlight";
import VirtualList from "react-virtual-list"; // or any virtualization library
function VirtualizedSearchList() {
const [searchTerm, setSearchTerm] = useState("");
const [visibleRows, setVisibleRows] = useState([]);
const listRef = useRef<HTMLDivElement>(null);
const { matchCount, refresh } = useHighlight({
search: searchTerm,
targetRef: listRef,
debounce: 300,
});
// Re-highlight when visible rows change (virtualization updates DOM)
useEffect(() => {
refresh();
}, [visibleRows, refresh]);
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search in list..."
/>
<p>Found {matchCount} matches</p>
<div ref={listRef}>
<VirtualList
items={items}
onVisibleRowsChange={setVisibleRows}
>
{(item) => <div>{item.content}</div>}
</VirtualList>
</div>
</div>
);
}function InteractiveSearch() {
const [searchTerm, setSearchTerm] = useState("");
const [caseSensitive, setCaseSensitive] = useState(false);
const [matchCount, setMatchCount] = useState(0);
const contentRef = useRef<HTMLDivElement>(null);
return (
<div>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
<label>
<input
type="checkbox"
checked={caseSensitive}
onChange={(e) => setCaseSensitive(e.target.checked)}
/>
Case sensitive
</label>
<p>Found {matchCount} matches</p>
{/* Debounce prevents excessive updates while typing */}
<Highlight
search={searchTerm}
targetRef={contentRef}
caseSensitive={caseSensitive}
debounce={300} // Wait 300ms after user stops typing
onHighlightChange={setMatchCount}
/>
<div ref={contentRef}>
{/* Your content here */}
</div>
</div>
);
}function CustomDebounceExample() {
const [searchTerm, setSearchTerm] = useState("");
const contentRef = useRef<HTMLDivElement>(null);
return (
<>
{/* No debounce - immediate updates */}
<Highlight
search={searchTerm}
targetRef={contentRef}
debounce={0}
/>
{/* Long debounce for expensive operations */}
<Highlight
search={searchTerm}
targetRef={largeContentRef}
debounce={500}
maxHighlights={500}
/>
{/* Alternative: Use the exported useDebounce hook */}
<SearchWithCustomDebounce />
</>
);
}
// You can also use the exported useDebounce hook directly
import { useDebounce } from "@/components/general/Highlight";
function SearchWithCustomDebounce() {
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearch = useDebounce(searchTerm, 300);
const contentRef = useRef<HTMLDivElement>(null);
return (
<>
<input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<Highlight
search={debouncedSearch}
targetRef={contentRef}
debounce={0} // Already debounced manually
/>
<div ref={contentRef}>{content}</div>
</>
);
}function ColorCodedSearch() {
const contentRef = useRef<HTMLDivElement>(null);
return (
<>
<Highlight
search={["TODO", "FIXME"]}
targetRef={contentRef}
highlightName="highlight-warning"
/>
<Highlight
search={["DONE", "FIXED"]}
targetRef={contentRef}
highlightName="highlight-success"
/>
<Highlight
search={["BUG", "ERROR"]}
targetRef={contentRef}
highlightName="highlight-error"
/>
<pre ref={contentRef}>
{codeContent}
</pre>
</>
);
}import { createPortal } from "react-dom";
function ModalWithHighlight() {
const modalRef = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
<Highlight search="important" targetRef={modalRef} />
{isOpen && createPortal(
<div ref={modalRef} className="modal">
<p>This is important information in a portal.</p>
</div>,
document.body
)}
</>
);
}function RobustSearch() {
const [error, setError] = useState<Error | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
return (
<>
<Highlight
search={userInput}
targetRef={contentRef}
onError={(err) => {
console.error("Highlight error:", err);
setError(err);
}}
/>
{error && (
<div className="error">
Failed to highlight: {error.message}
</div>
)}
<div ref={contentRef}>{content}</div>
</>
);
}// ✅ Filter empty/short terms before passing
const validTerms = terms.filter(t => t.trim().length > 0);
<Highlight search={validTerms} targetRef={ref} />
// ✅ Use reasonable maxHighlights for large documents
<Highlight search="term" targetRef={ref} maxHighlights={500} />
// ✅ Memoize search terms if they're derived from props
const searchTerms = useMemo(() =>
extractTerms(props.query),
[props.query]
);
// ✅ Use wholeWord for precise matching
<Highlight search="cat" targetRef={ref} wholeWord />
// Only matches "cat", not "category" or "scatter"
// ✅ Provide meaningful highlightName for multiple highlights
<Highlight search="error" highlightName="log-error" />
<Highlight search="warning" highlightName="log-warning" />// ❌ Don't create highlights on every render
{items.map(item =>
<Highlight search={item.term} targetRef={ref} key={item.id} />
)}
// This creates N highlights! Use array instead:
<Highlight search={items.map(i => i.term)} targetRef={ref} />
// ❌ Don't use extremely high maxHighlights
<Highlight search="a" maxHighlights={999999} /> // Will freeze browser!
// ❌ Don't highlight on input change without debounce
<input onChange={(e) => setSearch(e.target.value)} />
<Highlight search={search} targetRef={ref} debounce={0} /> // Will update on every keystroke!
// ✅ Use the built-in debounce prop (recommended)
<Highlight search={search} targetRef={ref} debounce={300} />
// ✅ Or debounce manually using the exported hook
const debouncedSearch = useDebounce(search, 300);
<Highlight search={debouncedSearch} targetRef={ref} />
// ❌ Don't pass empty strings
<Highlight search={["", "term", ""]} /> // Filter first!
// ❌ Don't use wrapper pattern for complex scenarios
<HighlightWrapper>
<HighlightWrapper> // Nested = bad
<Content />
</HighlightWrapper>
</HighlightWrapper>Check:
- Browser supports CSS Custom Highlight API (Chrome 105+, Safari 17.2+)
targetRef.currentis not null (component is mounted)- Search terms are not empty strings
- Content actually contains the search terms
- Check browser console for errors
// Debug helper
<Highlight
search="term"
targetRef={ref}
onHighlightChange={(count) => console.log(`Found ${count} matches`)}
onError={(err) => console.error(err)}
/>Solutions:
- Use the built-in
debounceprop (default is 100ms) - Reduce
maxHighlights(default is 1000) - Filter out short/common terms
- Break large documents into smaller sections
// Use built-in debounce (recommended)
<Highlight
search={searchTerm}
targetRef={ref}
debounce={300} // Wait 300ms after changes
maxHighlights={300} // Lower limit
/>
// Or debounce manually
const debouncedSearch = useDebounce(searchTerm, 300);
<Highlight
search={debouncedSearch}
targetRef={ref}
debounce={0} // Already debounced
maxHighlights={300}
/>Solution: For dynamic content (virtualized lists, infinite scroll, etc.), use the refresh() callback:
// Using the hook with refresh
const { refresh } = useHighlight({
search: "term",
targetRef: contentRef
});
// Re-highlight after content changes
useEffect(() => {
refresh();
}, [contentVersion, refresh]);Alternative: Force re-render the Highlight component with a key:
// Works but less efficient
<Highlight
key={contentVersion}
search="term"
targetRef={ref}
/>The component automatically skips:
<script><style><noscript><iframe><textarea>
For additional exclusions, wrap excluded content in a container and don't pass its ref.
// ❌ Wrong
const ref = useRef<HTMLDivElement>();
// ✅ Correct
const ref = useRef<HTMLDivElement>(null);┌───────────────────────────────────────────────────────────┐
│ React Layer │
│ useHighlight Hook (React-specific concerns) │
│ - State management (matchCount, error, isSupported) │
│ - Effect lifecycle & cleanup │
│ - Debouncing user input │
│ - Callback stability (useEffectEvent) │
└─────────────────────┬─────────────────────────────────────┘
│ Delegates to
┌─────────────────────▼─────────────────────────────────────┐
│ Vanilla Core │
│ createHighlight · createCompareHighlight (both agnostic) │
│ - DOM traversal · regex search / positional compare │
│ - CSS Custom Highlight API integration │
│ - Async scheduling (requestIdleCallback) │
│ - Used by React, Vue, Svelte, Angular, etc. │
└───────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ 1. User provides search terms + targetRef │
└──────────────────┬──────────────────────────────────┘
│
┌──────────────────▼──────────────────────────────────┐
│ 2. Normalize and validate input │
│ - Trim whitespace, filter empty strings │
│ - Pre-compile regex patterns (once) │
│ - Escape special characters │
└──────────────────┬──────────────────────────────────┘
│
┌──────────────────▼──────────────────────────────────┐
│ 3. TreeWalker traverses DOM text nodes [SYNC] │
│ - Skip SCRIPT, STYLE, empty nodes │
│ - Process only TEXT_NODE types │
│ - Calculate match count immediately │
└──────────────────┬──────────────────────────────────┘
│
┌──────────────────▼──────────────────────────────────┐
│ 4. Create Range objects for matches [SYNC] │
│ - Calculate start/end offsets │
│ - Store ranges in array │
│ - Update matchCount & call onChange │
└──────────────────┬──────────────────────────────────┘
│
┌──────────────────▼──────────────────────────────────┐
│ 5. Schedule visual update [ASYNC] │
│ - requestIdleCallback queues update │
│ - Waits for browser idle time │
│ - Non-blocking, cancellable │
└──────────────────┬──────────────────────────────────┘
│ (when browser is idle)
┌──────────────────▼──────────────────────────────────┐
│ 6. Register with CSS.highlights API [ASYNC] │
│ - Create Highlight(...ranges) │
│ - CSS.highlights.set(name, highlight) │
└──────────────────┬──────────────────────────────────┘
│
┌──────────────────▼──────────────────────────────────┐
│ 7. Browser applies ::highlight() CSS styles │
│ - Non-invasive (no DOM mutation) │
│ - Hardware accelerated │
└─────────────────────────────────────────────────────┘