Sticky Summary Row — Pinned Totals Footer HTML CSS JS
Sticky Summary/Totals Row · Tables · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="tsr-card">
<div class="tsr-head">
<h3>Monthly expenses</h3>
<p class="tsr-hint">Scroll the table — totals stay pinned to the bottom.</p>
</div>
<div class="tsr-scroll" id="tsrScroll">
<table class="tsr-table">
<thead><tr><th>Category</th><th class="tsr-num">Budget</th><th class="tsr-num">Spent</th><th class="tsr-num">Remaining</th></tr></thead>
<tbody id="tsrBody"></tbody>
<tfoot><tr id="tsrSummary"></tr></tfoot>
</table>
</div>
</div>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0e1420;color:#e5e9f5;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.tsr-card{background:#161d2e;border-radius:14px;padding:16px;width:100%;max-width:560px;border:1px solid #242c42;box-shadow:0 18px 44px rgba(0,0,0,.4)}
.tsr-head{margin-bottom:10px}
.tsr-head h3{font-size:14px;font-weight:800}
.tsr-hint{font-size:11px;color:#8791ae;font-weight:600;margin-top:2px}
.tsr-scroll{max-height:300px;overflow-y:auto;border:1px solid #242c42;border-radius:10px}
.tsr-table{width:100%;border-collapse:collapse;font-size:12.5px}
.tsr-table thead th{position:sticky;top:0;background:#1b2338;color:#8b94b8;font-weight:700;font-size:10.5px;text-transform:uppercase;letter-spacing:.03em;text-align:left;padding:9px 12px;border-bottom:1.5px solid #242c42;z-index:2}
.tsr-num{text-align:right!important;font-variant-numeric:tabular-nums}
.tsr-table tbody td{padding:9px 12px;border-bottom:1px solid #1e2537;color:#d3d8ec}
.tsr-table tbody tr:hover{background:#1a2136}
.tsr-table tfoot td{position:sticky;bottom:0;background:#20293f;color:#f4f6fc;font-weight:800;padding:10px 12px;border-top:2px solid #6366f1;z-index:3}
.tsr-table tfoot tr{position:sticky;bottom:0}var CATEGORIES = [
{ name: 'Engineering tools', budget: 12000, spent: 9800 },
{ name: 'Cloud infrastructure', budget: 34000, spent: 38200 },
{ name: 'Office & facilities', budget: 8000, spent: 6100 },
{ name: 'Marketing campaigns', budget: 21000, spent: 19850 },
{ name: 'Travel & events', budget: 6000, spent: 4300 },
{ name: 'Recruiting', budget: 15000, spent: 11200 },
{ name: 'Software licenses', budget: 9000, spent: 9400 },
{ name: 'Contractor fees', budget: 18000, spent: 16750 },
];
function money(n) { return '$' + n.toLocaleString(); }
var body = document.getElementById('tsrBody');
body.innerHTML = CATEGORIES.map(function (c) {
var remaining = c.budget - c.spent;
var color = remaining < 0 ? '#f87171' : '#d3d8ec';
return '<tr>' +
'<td>' + c.name + '</td>' +
'<td class="tsr-num">' + money(c.budget) + '</td>' +
'<td class="tsr-num">' + money(c.spent) + '</td>' +
'<td class="tsr-num" style="color:' + color + '">' + money(remaining) + '</td>' +
'</tr>';
}).join('');
// The totals row is computed live from the actual row data, never hardcoded.
function computeTotals(rows) {
return rows.reduce(function (acc, r) {
acc.budget += r.budget;
acc.spent += r.spent;
return acc;
}, { budget: 0, spent: 0 });
}
var totals = computeTotals(CATEGORIES);
var remainingTotal = totals.budget - totals.spent;
document.getElementById('tsrSummary').innerHTML =
'<td>Total</td>' +
'<td class="tsr-num">' + money(totals.budget) + '</td>' +
'<td class="tsr-num">' + money(totals.spent) + '</td>' +
'<td class="tsr-num" style="color:' + (remainingTotal < 0 ? '#f87171' : '#4ade80') + '">' + money(remainingTotal) + '</td>';Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Sticky Summary Row — Pinned Totals Computed Live From Real Row Data

A totals row buried at the bottom of a long scrollable table is only useful the moment you happen to scroll all the way down to it — the rest of the time, the sums you actually want to reference while scanning the data are out of view. This snippet keeps the summary row pinned to the bottom of the visible scroll area at all times using genuine position: sticky, with every total computed live from the row data rather than typed in by hand.
A tfoot pinned with position: sticky, not a fixed overlay
The totals row lives in a real <tfoot>, and its cells get position: sticky; bottom: 0 within the scrolling container — the browser-native mechanism for pinning an element to an edge of its nearest scrolling ancestor. This is the vertical counterpart to a sticky header table, which pins the <thead> to top: 0 the same way; here it's the footer pinned to the bottom instead, so both ends of a long table can stay visible simultaneously while only the body scrolls between them.
Real aggregation, not a hardcoded row
computeTotals() runs a genuine Array.prototype.reduce over the live CATEGORIES array, summing the budget and spent fields across every row into a single accumulator object — nothing about the totals row's numbers is typed in directly. Change a value in the source data, add or remove a category, and the totals recompute correctly the next time this function runs, because they are a direct function of the actual rows, never a separately maintained number that could drift out of sync.
A derived total, not just two summed totals
The remaining-budget total isn't summed directly from a "remaining" field — it's derived as totals.budget - totals.spent after the two real column sums are already computed, matching how the per-row remaining values are themselves computed as budget - spent rather than stored. This keeps a single consistent definition of "remaining" used both per-row and in aggregate, so the footer's math always agrees with what you'd get by manually summing every row's own remaining column.
Elevated z-index so scrolling content passes beneath it cleanly
As body rows scroll past underneath the pinned footer, the summary row needs a higher z-index and an opaque background or scrolling rows would visibly show through it — set here alongside a distinct top border and heavier font weight so the summary row also reads visually as the "final word" on the data above it, not just another row.
Consistent styling logic between rows and the total
The negative-remaining highlight (a warning color when a category is over budget) uses the exact same conditional check in both the per-row loop and the summary computation — remaining < 0 — so the visual language for "over budget" is identical whether you're looking at one category or the aggregate across all of them.
Customizing it
Add an average alongside the sum, compute per-column totals generically by iterating column keys instead of naming each field, or combine with a virtualized table for a sticky summary over a very large scrolling dataset.
Step by step
How to Use
- 1Paste HTML, CSS, and JSAn eight-row expense table renders with a Total row already visible at the bottom.
- 2Scroll the table bodyThe Total row stays pinned to the bottom of the visible scroll area as rows pass beneath it.
- 3Check the numbersBudget, Spent, and Remaining totals are computed live from the actual row values via reduce.
- 4Edit a row's budget or spent valueThe totals recompute the next render — nothing about the summary is hand-typed.
- 5Add or remove a categoryThe totals update correctly to reflect exactly the rows currently in the data array.
- 6Watch an over-budget categoryIts Remaining value and the aggregate Remaining both use the same negative-value highlight.
Real-world uses
Common Use Cases
bottom: 0 of the scroll container.reduce over the row array on every render.Got questions?
Frequently Asked Questions
As an ordinary last row, the summary would scroll out of view along with the rest of the body the moment you scroll down through a long table — exactly when a running total is most useful to have visible. position: sticky with bottom: 0 keeps it pinned to the bottom edge of the scrolling container regardless of how far the body has scrolled, the same mechanism a sticky header uses at top: 0.
Both approaches produce the same number here since subtraction distributes over the sum, but computing it from the already-summed budget and spent totals keeps a single definition of "remaining" (budget minus spent) used consistently everywhere, rather than maintaining what amounts to the same calculation in two separate places that could theoretically diverge if the formula ever changed in only one spot.
Inside computeTotals, alongside the running budget and spent sums, either divide the final sums by rows.length after the reduce completes, or accumulate a count and divide inside the reduce callback — then render an additional summary value using that computed average the same way the sums are rendered.
Instead of naming budget and spent explicitly in computeTotals, pass in an array of column keys to sum and use reduce with a loop over those keys (acc[key] = (acc[key] || 0) + row[key]) inside the reducer — this way adding a new numeric column to the row data only requires adding its key to the columns array, not editing the aggregation function itself.
Compute the totals with the same reduce logic inside a memoized calculation (useMemo, a computed property, or a getter) that re-runs when the underlying row data changes, and render the tfoot's sticky-positioned cells from that computed value — the CSS position:sticky behavior needs no JavaScript and ports unchanged into any framework's table markup.