Skip to content

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.

TypeScript
/* file: app.component.ts */
import { Component } from '@angular/core';
import { GridSettings, HotTableModule } from '@handsontable/angular-wrapper';
import { RowObject } from 'handsontable/common';
import Handsontable from 'handsontable/base';
const SUMMARY_SOURCE = 'updateSummary';
type Row = {
item: string;
units: number | string;
price: number | string;
tax: number | string;
};
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 tableData: 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 = tableData.length - 1;
function formatSummary(prop: keyof Row): string {
const numbers: number[] = [];
for (let row = 0; row < summaryRowIndex; row += 1) {
const n = parseNumeric(tableData[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);
});
});
}
@Component({
standalone: true,
imports: [HotTableModule],
selector: 'example1-frozen-summary-row',
template: `
<div>
<hot-table [data]="data" [settings]="gridSettings"></hot-table>
</div>
`,
})
export class AppComponent {
readonly data = tableData;
readonly gridSettings: GridSettings = {
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(row: number, _col: number, prop: string | number): Handsontable.CellMeta {
if (row !== summaryRowIndex) {
return {};
}
const meta: Handsontable.CellMeta = {
readOnly: true,
className: 'htSummaryRow',
};
if (prop !== 'item') {
meta.type = 'text';
meta.className = 'htSummaryRow htRight';
}
return meta;
},
afterInit(this: Handsontable): void {
refreshSummary(this);
},
afterChange(
this: Handsontable,
changes: Handsontable.CellChange[] | null,
source: Handsontable.ChangeSource,
): void {
if (!changes || (source as string) === SUMMARY_SOURCE) {
return;
}
if (changes.every(([row]) => row === summaryRowIndex)) {
return;
}
refreshSummary(this);
},
beforeUndoStackChange(
_doneActions: unknown[],
source: string | undefined,
): boolean | void {
if (source === SUMMARY_SOURCE) {
return false;
}
},
};
}
/* 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-frozen-summary-row></example1-frozen-summary-row>
</div>
CSS
.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 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).

    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.

    Prefer Handsontable theme variables (for example --ht-background-color-extra-light) 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-color-extra-light) in your summary row styles so the row stays visually consistent across themes.

Next steps

API reference