Skip to content

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.

JavaScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
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)}\nAvg: ${avg.toFixed(2)}\nCount: ${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 container = document.querySelector('#example1');
// eslint-disable-next-line no-unused-vars -- recipe keeps instance for debugging
const hot = new Handsontable(container, {
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: { maximumFractionDigits: 0 } },
{ data: 'price', type: 'numeric', numericFormat: { minimumFractionDigits: 2, maximumFractionDigits: 2 } },
{ data: 'tax', type: 'numeric', numericFormat: { minimumFractionDigits: 2, maximumFractionDigits: 2 } },
],
cells(row, _col, prop) {
if (row !== summaryRowIndex) {
return {};
}
const meta = {
readOnly: true,
className: 'htSummaryRow',
};
if (prop !== 'item') {
meta.type = 'text';
meta.validator = undefined;
meta.className = 'htSummaryRow htRight';
}
return meta;
},
afterInit() {
refreshSummary(this);
},
afterChange(changes, source) {
if (!changes || source === SUMMARY_SOURCE) {
return;
}
if (changes.every(([row]) => row === summaryRowIndex)) {
return;
}
refreshSummary(this);
},
beforeUndoStackChange(_doneActions, source) {
if (source === SUMMARY_SOURCE) {
return false;
}
},
});
TypeScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
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)}\nAvg: ${avg.toFixed(2)}\nCount: ${numbers.length}`;
}
function refreshSummary(hot: Handsontable) {
hot.batch(() => {
hot.setDataAtRowProp(summaryRowIndex, 'item', 'Totals', SUMMARY_SOURCE);
numericProps.forEach((prop) => {
hot.setDataAtRowProp(summaryRowIndex, prop, formatSummary(prop), SUMMARY_SOURCE);
});
});
}
const container = document.querySelector('#example1')!;
// eslint-disable-next-line no-unused-vars -- recipe keeps instance for debugging
const hot = new Handsontable(container, {
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: { maximumFractionDigits: 0 } },
{ data: 'price', type: 'numeric', numericFormat: { minimumFractionDigits: 2, maximumFractionDigits: 2 } },
{ data: 'tax', type: 'numeric', numericFormat: { minimumFractionDigits: 2, maximumFractionDigits: 2 } },
],
cells(row, _col, prop) {
if (row !== summaryRowIndex) {
return {};
}
const meta: Handsontable.CellProperties = {
readOnly: true,
className: 'htSummaryRow',
};
if (prop !== 'item') {
meta.type = 'text';
meta.validator = undefined;
meta.className = 'htSummaryRow htRight';
}
return meta;
},
afterInit() {
refreshSummary(this);
},
afterChange(changes, source) {
if (!changes || source === SUMMARY_SOURCE) {
return;
}
if (changes.every(([row]) => row === summaryRowIndex)) {
return;
}
refreshSummary(this);
},
beforeUndoStackChange(_doneActions, source) {
if (source === SUMMARY_SOURCE) {
return false;
}
},
});
CSS
.htSummaryRow {
font-weight: 600;
}
/* The summary row is always read-only, so Handsontable applies its built-in .htDimmed
background with `!important`. Match that specificity and add `!important` here too,
so this rule - not the default read-only style - decides the summary row's background. */
.handsontable .htSummaryRow.htDimmed {
background-color: var(--ht-background-secondary-color, #f2f3f5) !important;
}

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 cells callback.

Prerequisites

  1. 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 in data lets you use normal APIs (setDataAtRowProp, renderers, validators) like any other row.

  2. Freeze the bottom row

    Set fixedRowsBottom: 1 so the summary row is always visible at the bottom of the viewport.

  3. 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 null for 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).

    Join the three stats with a newline (\n) instead of a single line, so Sum, Avg, and Count each render on their own line inside the cell. The grid’s default white-space: pre-wrap cell style turns that \n into a real line break, so the count never gets stranded mid-wrap next to the other stats.

    Write the formatted string into the summary cells with setDataAtRowProp.

  4. Recalculate on load and on edits

    • Call your refresh function from afterInit so the summary is correct on first render.
    • Call it from afterChange whenever a data cell changes.

    Pass a custom source string (for example updateSummary) into setDataAtRowProp so your afterChange handler can ignore changes that only update the summary row. That avoids extra refresh passes or feedback loops.

  5. Make the summary row read-only and styled

    Use the cells callback (row, column, prop) to return metadata only for the summary row:

    • Set readOnly: true so users cannot edit totals.
    • Set className (for example htSummaryRow, and htRight on 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.
    • Also set validator: undefined on those same cells. Overriding type on a cell whose column has a different type does not clear a property the column’s type already set - the text cell type declares no validator, so the cell keeps inheriting the column’s numeric validator. That validator then fails on the aggregate text and marks the cell invalid, which Handsontable renders with its built-in error background instead of your htSummaryRow style.

    A read-only cell also gets Handsontable’s built-in .htDimmed background, applied with !important. Give your own background rule at least the same specificity and add !important too - for example .handsontable .htSummaryRow.htDimmed { background-color: ...; } - so it wins instead of silently losing to the default read-only style. Prefer Handsontable theme variables (for example --ht-background-secondary-color) so the row still looks correct with different themes.

What you learned

  • How fixedRowsBottom pins the last N rows at the bottom of the grid so they stay visible during scrolling.
  • How to recalculate summary values in afterChange and afterInit and write them back with hot.setDataAtRowProp().
  • How to use the cells callback to mark only the summary row as readOnly and apply a custom className for styling.
  • Why you should use Handsontable theme CSS variables (such as --ht-background-secondary-color) in your summary row styles so the row stays visually consistent across themes, and why a custom background rule on a read-only row needs to match or exceed the specificity of Handsontable’s built-in .htDimmed style to actually take effect.

Next steps

API reference