Highlight search matches
In this tutorial, you will highlight matched text fragments inside cells using a custom renderer. You will learn how to wrap matching substrings in <mark> tags safely and keep the highlights in sync with an external search input.
import { useRef } from 'react';import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';import { rendererFactory } from 'handsontable/renderers';
registerAllModules();
const data = [ { id: 101, title: 'Search API docs', owner: 'Alex', status: 'In progress' }, { id: 102, title: 'Renderer refactor', owner: 'Mia', status: 'Review' }, { id: 103, title: 'Fix keyboard shortcut', owner: 'Noah', status: 'Done' }, { id: 104, title: 'Search UX tests', owner: 'Ava', status: 'In progress' },];
let currentSearchTerm = '';
function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');}
function escapeHtml(value) { return value .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''');}
const highlightRenderer = rendererFactory(({ td, value, cellProperties }) => { const cellText = value === null || value === undefined ? '' : String(value); const query = currentSearchTerm.trim();
if (!query || !cellProperties.isSearchResult) { td.textContent = cellText; return; }
const splitter = new RegExp(`(${escapeRegExp(query)})`, 'gi'); const highlighted = cellText .split(splitter) .map((fragment) => fragment.toLocaleLowerCase() === query.toLocaleLowerCase() ? `<mark>${escapeHtml(fragment)}</mark>` : escapeHtml(fragment) ) .join('');
td.innerHTML = highlighted;});
const ExampleComponent = () => { const hotRef = useRef(null);
function handleSearchInput(e) { const hot = hotRef.current?.hotInstance;
if (!hot) { return; }
currentSearchTerm = e.target.value; hot.getPlugin('search').query(currentSearchTerm); hot.render(); }
return ( <div> <div className="example-controls-container"> <div className="controls"> <input type="search" placeholder="Search tasks" onInput={handleSearchInput} /> </div> </div> <HotTable ref={hotRef} data={data} colHeaders={['ID', 'Title', 'Owner', 'Status']} rowHeaders={true} height="auto" search={true} columns={[ { data: 'id', type: 'numeric' }, { data: 'title', renderer: highlightRenderer }, { data: 'owner', renderer: highlightRenderer }, { data: 'status', renderer: highlightRenderer }, ]} licenseKey="non-commercial-and-evaluation" /> </div> );};
export default ExampleComponent;import { useRef } from 'react';import { HotTable, HotTableRef } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';import { rendererFactory } from 'handsontable/renderers';
registerAllModules();
const data = [ { id: 101, title: 'Search API docs', owner: 'Alex', status: 'In progress' }, { id: 102, title: 'Renderer refactor', owner: 'Mia', status: 'Review' }, { id: 103, title: 'Fix keyboard shortcut', owner: 'Noah', status: 'Done' }, { id: 104, title: 'Search UX tests', owner: 'Ava', status: 'In progress' },];
let currentSearchTerm = '';
function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');}
function escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''');}
const highlightRenderer = rendererFactory(({ td, value, cellProperties }) => { const cellText = value === null || value === undefined ? '' : String(value); const query = currentSearchTerm.trim();
if (!query || !cellProperties.isSearchResult) { td.textContent = cellText; return; }
const splitter = new RegExp(`(${escapeRegExp(query)})`, 'gi'); const highlighted = cellText .split(splitter) .map((fragment) => fragment.toLocaleLowerCase() === query.toLocaleLowerCase() ? `<mark>${escapeHtml(fragment)}</mark>` : escapeHtml(fragment) ) .join('');
td.innerHTML = highlighted;});
const ExampleComponent = () => { const hotRef = useRef<HotTableRef>(null);
function handleSearchInput(e: React.FormEvent<HTMLInputElement>) { const hot = hotRef.current?.hotInstance;
if (!hot) { return; }
currentSearchTerm = e.currentTarget.value; hot.getPlugin('search').query(currentSearchTerm); hot.render(); }
return ( <div> <div className="example-controls-container"> <div className="controls"> <input type="search" placeholder="Search tasks" onInput={handleSearchInput} /> </div> </div> <HotTable ref={hotRef} data={data} colHeaders={['ID', 'Title', 'Owner', 'Status']} rowHeaders={true} height="auto" search={true} columns={[ { data: 'id', type: 'numeric' }, { data: 'title', renderer: highlightRenderer }, { data: 'owner', renderer: highlightRenderer }, { data: 'status', renderer: highlightRenderer }, ]} licenseKey="non-commercial-and-evaluation" /> </div> );};
export default ExampleComponent;Overview
This recipe shows how to highlight matched text fragments with a custom renderer that wraps matches in a <mark> element. You can use this approach when you want richer highlighting than the default Search plugin class.
Difficulty: Beginner Time: ~15 minutes Libraries: None (pure Handsontable + browser APIs)
What You’ll Build
A search experience that:
- Highlights only matched fragments inside each cell with
<mark> - Keeps non-matching cells unchanged
- Escapes both user input and cell values before inserting HTML
- Updates in real time when the user types in an external search field
- Uses
search.query()to keepisSearchResultmetadata in sync
Import dependencies
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { Search } from 'handsontable/plugins';import { rendererFactory } from 'handsontable/renderers';registerAllModules();Use
rendererFactoryto build a custom renderer, and use theSearchplugin for match detection.Keep the search term in external state
let currentSearchTerm = '';The renderer reads
currentSearchTerm, so typing in the external input updates rendering aftersearch.query()andhot.render().Add safe HTML helpers
function escapeHtml(value: string): string {return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');}Because the renderer sets
innerHTML, escape both the source text and the query before composing HTML. This prevents injecting arbitrary markup from data or user input.Build a
<mark>rendererconst highlightRenderer = rendererFactory(({ td, value, cellProperties }) => {const cellText = value === null || value === undefined ? '' : String(value);const query = currentSearchTerm.trim();if (!query || !cellProperties.isSearchResult) {td.textContent = cellText;return;}const escapedCellText = escapeHtml(cellText);const escapedQuery = escapeHtml(query);const splitter = new RegExp(`(${escapeRegExp(escapedQuery)})`, 'gi');const highlighted = escapedCellText.split(splitter).map(fragment => (fragment.toLocaleLowerCase() === escapedQuery.toLocaleLowerCase()? `<mark>${fragment}</mark>`: fragment)).join('');td.innerHTML = highlighted;});This renderer:
- Renders plain text for non-matches
- Highlights only cells flagged by the Search plugin
- Wraps matching fragments with
<mark>
Wire the external input to the Search plugin
searchField.addEventListener('input', (event) => {currentSearchTerm = (event.target as HTMLInputElement).value;searchPlugin.query(currentSearchTerm);hot.render();});This keeps metadata and rendering synchronized on every keystroke.
Register the renderer
You can register the custom renderer in two ways:
- Per-column, by setting
renderer: highlightRendereronly on selected columns. - Globally, by setting
renderer: highlightRendererin the root Handsontable options.
This recipe uses per-column registration so numeric IDs keep the default renderer.
- Per-column, by setting
Complete example
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { Search } from 'handsontable/plugins';import { rendererFactory } from 'handsontable/renderers';
registerAllModules();
type RowData = { id: number; title: string; owner: string; status: string;};
const data: RowData[] = [ { id: 101, title: 'Search API docs', owner: 'Alex', status: 'In progress' }, { id: 102, title: 'Renderer refactor', owner: 'Mia', status: 'Review' }, { id: 103, title: 'Fix keyboard shortcut', owner: 'Noah', status: 'Done' }, { id: 104, title: 'Search UX tests', owner: 'Ava', status: 'In progress' },];
let currentSearchTerm = '';
function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');}
function escapeHtml(value: string): string { return value .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, ''');}
const highlightRenderer = rendererFactory(({ td, value, cellProperties }) => { const cellText = value === null || value === undefined ? '' : String(value); const query = currentSearchTerm.trim();
if (!query || !cellProperties.isSearchResult) { td.textContent = cellText; return; }
const escapedCellText = escapeHtml(cellText); const escapedQuery = escapeHtml(query); const splitter = new RegExp(`(${escapeRegExp(escapedQuery)})`, 'gi'); const highlighted = escapedCellText .split(splitter) .map(fragment => ( fragment.toLocaleLowerCase() === escapedQuery.toLocaleLowerCase() ? `<mark>${fragment}</mark>` : fragment )) .join('');
td.innerHTML = highlighted;});
const container = document.querySelector('#example1')!;const searchField = document.querySelector('#search_field')!;
const hot = new Handsontable(container, { data, colHeaders: ['ID', 'Title', 'Owner', 'Status'], rowHeaders: true, height: 'auto', search: true, columns: [ { data: 'id', type: 'numeric' }, { data: 'title', renderer: highlightRenderer }, { data: 'owner', renderer: highlightRenderer }, { data: 'status', renderer: highlightRenderer }, ], licenseKey: 'non-commercial-and-evaluation',});
const searchPlugin: Search = hot.getPlugin('search');
searchField.addEventListener('input', (event) => { currentSearchTerm = (event.target as HTMLInputElement).value; searchPlugin.query(currentSearchTerm); hot.render();});How it works
- User types in the external search field.
search.query()marks matching cells withisSearchResult.hot.render()runs the custom renderer.- Matching fragments are wrapped in
<mark>. - Non-matching cells render as plain text.
Related
What you learned
- How to write a custom cell renderer that reads the
isSearchResultflag and the current search term from the plugin state. - How to safely insert
<mark>tags around matched text fragments to highlight partial matches inside cell content. - Why you must escape special regex characters in the search term before building a
RegExpto avoid runtime errors. - How to combine
search.query()andhot.render()to keep highlights in sync with every keystroke.
Next steps
- Explore external search box for a simpler approach using the default
htSearchResulthighlight class without a custom renderer. - Extend the renderer to also highlight matches in the column header row by overriding
afterGetColHeader.