Skip to content

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.

TypeScript
/* file: app.component.ts */
import { Component, ViewChild } from '@angular/core';
import { GridSettings, HotTableComponent, HotTableModule } from '@handsontable/angular-wrapper';
import { RowObject } from 'handsontable/common';
import { rendererFactory } from 'handsontable/renderers';
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' },
];
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
let currentSearchTerm = '';
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;
});
@Component({
standalone: true,
imports: [HotTableModule],
selector: 'example1-highlight-search-matches',
template: `
<div class="example-controls-container">
<div class="controls">
<input
id="search_field"
type="search"
placeholder="Search tasks"
(input)="onSearch($event)"
/>
</div>
</div>
<hot-table [data]="data" [settings]="gridSettings"></hot-table>
`,
})
export class AppComponent {
@ViewChild(HotTableComponent, { static: false }) readonly hotTable!: HotTableComponent;
readonly data = data;
readonly gridSettings: GridSettings = {
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 },
],
};
onSearch(event: Event): void {
const value = (event.target as HTMLInputElement).value;
const hot = this.hotTable.hotInstance;
if (!hot) {
return;
}
currentSearchTerm = value;
hot.getPlugin('search').query(value);
hot.render();
}
}
/* end-file */
/* file: app.config.ts */
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { registerAllModules } from 'handsontable/registry';
import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper';
registerAllModules();
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
{
provide: HOT_GLOBAL_CONFIG,
useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig,
},
],
};
/* end-file */
HTML
<div><example1-highlight-search-matches></example1-highlight-search-matches></div>

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 keep isSearchResult metadata in sync
  1. Import dependencies

    import Handsontable from 'handsontable/base';
    import { registerAllModules } from 'handsontable/registry';
    import { Search } from 'handsontable/plugins';
    import { rendererFactory } from 'handsontable/renderers';
    registerAllModules();

    Use rendererFactory to build a custom renderer, and use the Search plugin for match detection.

  2. Keep the search term in external state

    let currentSearchTerm = '';

    The renderer reads currentSearchTerm, so typing in the external input updates rendering after search.query() and hot.render().

  3. Add safe HTML helpers

    function escapeHtml(value: string): string {
    return value
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
    }

    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.

  4. Build a <mark> renderer

    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;
    });

    This renderer:

    • Renders plain text for non-matches
    • Highlights only cells flagged by the Search plugin
    • Wraps matching fragments with <mark>
  5. 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.

  6. Register the renderer

    You can register the custom renderer in two ways:

    • Per-column, by setting renderer: highlightRenderer only on selected columns.
    • Globally, by setting renderer: highlightRenderer in the root Handsontable options.

    This recipe uses per-column registration so numeric IDs keep the default renderer.

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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

  1. User types in the external search field.
  2. search.query() marks matching cells with isSearchResult.
  3. hot.render() runs the custom renderer.
  4. Matching fragments are wrapped in <mark>.
  5. Non-matching cells render as plain text.

What you learned

  • How to write a custom cell renderer that reads the isSearchResult flag 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 RegExp to avoid runtime errors.
  • How to combine search.query() and hot.render() to keep highlights in sync with every keystroke.

Next steps

  • Explore external search box for a simpler approach using the default htSearchResult highlight class without a custom renderer.
  • Extend the renderer to also highlight matches in the column header row by overriding afterGetColHeader.