Frozen summary row
In this tutorial, you will pin a read-only totals row at the bottom of the grid. You will learn how to use fixedRowsBottom, recalculate aggregates on afterChange, and style the summary row with the cells callback.
import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';import './example1.css';
registerAllModules();
const SUMMARY_SOURCE = 'updateSummary';
function parseNumeric(value) { if (typeof value === 'number' && Number.isFinite(value)) { return value; }
if (typeof value === 'string' && value.trim() !== '') { const n = Number(value);
if (Number.isFinite(n)) { return n; } }
return null;}
const data = [ { item: 'Module A', units: 12, price: 49.5, tax: 5.2 }, { item: 'Module B', units: 8, price: 120, tax: 8 }, { item: 'Module C', units: 3, price: 200, tax: '\u2014' }, { item: 'Module D', units: 15, price: 35, tax: 4.1 }, { item: 'Module E', units: 0, price: 75, tax: 6 }, { item: '', units: '', price: '', tax: '' },];
const numericProps = ['units', 'price', 'tax'];const summaryRowIndex = data.length - 1;
function formatSummary(prop) { const numbers = [];
for (let row = 0; row < summaryRowIndex; row += 1) { const n = parseNumeric(data[row][prop]);
if (n !== null) { numbers.push(n); } }
if (numbers.length === 0) { return '\u2014'; }
const sum = numbers.reduce((acc, n) => acc + n, 0); const avg = sum / numbers.length;
return `Sum: ${sum.toFixed(2)} · Avg: ${avg.toFixed(2)} · Count: ${numbers.length}`;}
function refreshSummary(hot) { hot.batch(() => { hot.setDataAtRowProp(summaryRowIndex, 'item', 'Totals', SUMMARY_SOURCE);
numericProps.forEach((prop) => { hot.setDataAtRowProp(summaryRowIndex, prop, formatSummary(prop), SUMMARY_SOURCE); }); });}
const ExampleComponent = () => { return ( <HotTable data={data} licenseKey="non-commercial-and-evaluation" rowHeaders={true} colHeaders={['Item', 'Units', 'Price', 'Tax']} fixedRowsBottom={1} height="auto" width="100%" columns={[ { data: 'item', type: 'text', readOnly: false }, { data: 'units', type: 'numeric', numericFormat: { pattern: '0' } }, { data: 'price', type: 'numeric', numericFormat: { pattern: '0.00' } }, { data: 'tax', type: 'numeric', numericFormat: { pattern: '0.00' } }, ]} cells={function (row, _col, prop) { if (row !== summaryRowIndex) { return {}; }
const meta = { readOnly: true, className: 'htSummaryRow', };
if (prop !== 'item') { meta.type = 'text'; meta.className = 'htSummaryRow htRight'; }
return meta; }} afterInit={function () { refreshSummary(this); }} afterChange={function (changes, source) { if (!changes || source === SUMMARY_SOURCE) { return; }
if (changes.every(([row]) => row === summaryRowIndex)) { return; }
refreshSummary(this); }} beforeUndoStackChange={function (_doneActions, source) { if (source === SUMMARY_SOURCE) { return false; } }} /> );};
export default ExampleComponent;import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';import type Handsontable from 'handsontable/base';import './example1.css';
registerAllModules();
type Row = { item: string; units: number | string; price: number | string; tax: number | string;};
const SUMMARY_SOURCE = 'updateSummary';
function parseNumeric(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) { return value; }
if (typeof value === 'string' && value.trim() !== '') { const n = Number(value);
if (Number.isFinite(n)) { return n; } }
return null;}
const data: Row[] = [ { item: 'Module A', units: 12, price: 49.5, tax: 5.2 }, { item: 'Module B', units: 8, price: 120, tax: 8 }, { item: 'Module C', units: 3, price: 200, tax: '\u2014' }, { item: 'Module D', units: 15, price: 35, tax: 4.1 }, { item: 'Module E', units: 0, price: 75, tax: 6 }, { item: '', units: '', price: '', tax: '' },];
const numericProps: (keyof Row)[] = ['units', 'price', 'tax'];const summaryRowIndex = data.length - 1;
function formatSummary(prop: keyof Row): string { const numbers: number[] = [];
for (let row = 0; row < summaryRowIndex; row += 1) { const n = parseNumeric(data[row][prop]);
if (n !== null) { numbers.push(n); } }
if (numbers.length === 0) { return '\u2014'; }
const sum = numbers.reduce((acc, n) => acc + n, 0); const avg = sum / numbers.length;
return `Sum: ${sum.toFixed(2)} · Avg: ${avg.toFixed(2)} · Count: ${numbers.length}`;}
function refreshSummary(hot: Handsontable): void { hot.batch(() => { hot.setDataAtRowProp(summaryRowIndex, 'item', 'Totals', SUMMARY_SOURCE);
numericProps.forEach((prop) => { hot.setDataAtRowProp(summaryRowIndex, prop, formatSummary(prop), SUMMARY_SOURCE); }); });}
const ExampleComponent = () => { return ( <HotTable data={data} licenseKey="non-commercial-and-evaluation" rowHeaders={true} colHeaders={['Item', 'Units', 'Price', 'Tax']} fixedRowsBottom={1} height="auto" width="100%" columns={[ { data: 'item', type: 'text', readOnly: false }, { data: 'units', type: 'numeric', numericFormat: { pattern: '0' } }, { data: 'price', type: 'numeric', numericFormat: { pattern: '0.00' } }, { data: 'tax', type: 'numeric', numericFormat: { pattern: '0.00' } }, ]} cells={function ( this: Handsontable.CellProperties, row: number, _col: number, prop: string | number, ): Handsontable.CellMeta { if (row !== summaryRowIndex) { return {}; }
const meta: Handsontable.CellProperties = { readOnly: true, className: 'htSummaryRow', };
if (prop !== 'item') { meta.type = 'text'; meta.className = 'htSummaryRow htRight'; }
return meta; }} afterInit={function (this: Handsontable): void { refreshSummary(this); }} afterChange={function ( this: Handsontable, changes: Handsontable.CellChange[] | null, source: Handsontable.ChangeSource, ): void { if (!changes || source === SUMMARY_SOURCE) { return; }
if (changes.every(([row]) => row === summaryRowIndex)) { return; }
refreshSummary(this); }} beforeUndoStackChange={function ( _doneActions: Handsontable.UndoRedoAction[], source: string | undefined, ): boolean | void { if (source === SUMMARY_SOURCE) { return false; } }} /> );};
export default ExampleComponent;.htSummaryRow { font-weight: 600; background-color: var(--ht-background-color-extra-light, #f2f3f5);}Overview
This recipe pins a single summary row to the bottom of the grid so it stays visible while you scroll. The row shows sum, average, and count for each numeric column, skips non-numeric values, stays read-only, and updates whenever data changes.
Difficulty: Intermediate Time: ~20 minutes Libraries: None (core Handsontable only)
What you will build
- A grid with at least five data rows and three numeric columns, plus one frozen bottom row.
- Aggregates that ignore the summary row and ignore values that are not finite numbers.
- Distinct styling (weight and background) applied through the
cellscallback.
Prerequisites
- Basic Handsontable setup with
registerAllModules. - Familiarity with
fixedRowsBottomandafterChange.
Include the summary row in your data
Add one extra row at the end of your dataset. That row is both the last logical row and the row you freeze with
fixedRowsBottom: 1. Keeping it indatalets you use normal APIs (setDataAtRowProp, renderers, validators) like any other row.Freeze the bottom row
Set
fixedRowsBottom: 1so the summary row is always visible at the bottom of the viewport.Compute sum, average, and count
For each numeric column, scan only data rows above the summary row. Use a small helper that returns a number for numeric strings and finite numbers, and
nullfor everything else - so stray text does not break the aggregation.For each column, compute:
- Sum - sum of parsed values.
- Avg - sum divided by how many numeric values you counted.
- Count - number of numeric values (not including blanks or non-numeric text).
Write the formatted string into the summary cells with
setDataAtRowProp.Recalculate on load and on edits
- Call your refresh function from
afterInitso the summary is correct on first render. - Call it from
afterChangewhenever a data cell changes.
Pass a custom
sourcestring (for exampleupdateSummary) intosetDataAtRowPropso yourafterChangehandler can ignore changes that only update the summary row. That avoids extra refresh passes or feedback loops.- Call your refresh function from
Make the summary row read-only and styled
Use the
cellscallback (row, column, prop) to return metadata only for the summary row:- Set
readOnly: trueso users cannot edit totals. - Set
className(for examplehtSummaryRow, andhtRighton numeric columns) and define those classes in a small CSS file. - For summary cells that display text aggregates, set
type: 'text'so Handsontable does not run the numeric cell renderer on those strings.
Prefer Handsontable theme variables (for example
--ht-background-color-extra-light) so the row still looks correct with different themes.- Set
Related guides
- Row freezing - fixed rows top and bottom.
- Column summary - built-in
ColumnSummaryplugin when you want declarative summaries instead of a custom row.
What you learned
- How
fixedRowsBottompins the last N rows at the bottom of the grid so they stay visible during scrolling. - How to recalculate summary values in
afterChangeandafterInitand write them back withhot.setDataAtRowProp(). - How to use the
cellscallback to mark only the summary row asreadOnlyand apply a customclassNamefor styling. - Why you should use Handsontable theme CSS variables (such as
--ht-background-color-extra-light) in your summary row styles so the row stays visually consistent across themes.
Next steps
- Explore conditional row coloring to style data rows based on cell values while keeping the summary row fixed.
- Explore the ColumnSummary plugin for a declarative, built-in alternative to a custom summary row.