More Tables Snippets
Pivot Table — Cross-Tab HTML CSS JS (No Library)
Pivot Table · Tables · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Pivot Table — Cross-Tabulate Flat Records by Row and Column with Totals

A pivot table turns a flat list of records into a cross-tabulation: pick a field for the rows, a field for the columns, and a measure for the cells, and it summarises the data into a grid with totals — the core of every spreadsheet pivot and BI tool. This snippet builds a working pivot table in plain HTML, CSS, and vanilla JavaScript, aggregating the data itself with switchable sum/average/count measures and full totals — no library.
Cross-tabulation from flat data
The input is a flat array of records (region, quarter, sales). The pivot derives the unique row values and unique column values, then for each row/column intersection filters the records that match both and aggregates them into a cell. This filter-and-aggregate at every intersection is the essence of a pivot — it reshapes one-row-per-event data into a summary matrix, which is exactly what makes raw transactional data readable.
Switchable measures
A selector changes the aggregation across the whole table — sum, average, or count — and re-pivots live. aggregate(records, how) is the single function that defines each measure: count is the record tally, sum totals the value field, average divides the two. Because every cell, row total, column total, and the grand total all route through this one function, switching the measure recomputes the entire grid consistently — there's no chance of the totals using a different calculation than the cells.
Totals on both axes
The table adds a Total column (each row aggregated across all columns), a Total row in the footer (each column aggregated across all rows), and a grand total at their intersection. Crucially, totals are computed from the *raw records*, not by summing the displayed cells — this matters for the average measure, where averaging the cell averages would be wrong; aggregating the underlying records gives the correct overall average. Getting totals right under non-additive measures is the detail that separates a real pivot from a naive grid.
Readable, scrollable layout
Row headers and the corner are left-aligned while numeric cells are right-aligned with tabular figures so columns line up, totals are emphasised with a tinted background, and the table scrolls horizontally if there are many columns. These are the conventions that make a dense numeric grid scannable.
Data-driven and drop-in
Change the ROW, COL, and VAL field names at the top to pivot any flat dataset by different dimensions, or wire those to selectors for a fully interactive pivot. It's a clear, dependency-free reference for the cross-tabulation and aggregation logic behind every pivot table. The filter-per-cell approach here is intentionally simple and easy to follow for a dataset this size; it scans the full DATA array once per intersection, so it's O(rows × cols × records) — fine for dozens of cells, but for a pivot over thousands of records you'd first group records once into a Map keyed by row+col, then read each cell's aggregate from that map in O(1). The same regrouping approach extends naturally to a pivot with more than two dimensions — a third "page" field, say — by keying the map on all three values and adding a selector that filters which page's slice is currently rendered, without changing how the row/column grid itself is built.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You don't have to work through the aggregation edge cases by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why the footer and row totals must aggregate the raw filtered records rather than summing the already-computed cell values, and specifically why that distinction matters for the average measure but not for sum or count. The same assistant can help optimize it, for example checking whether the current approach of filtering the full DATA array once per row/column intersection becomes a real bottleneck as the dataset grows, and how to replace it with a single grouping pass into a lookup map keyed by row and column. It's also useful for extending the effect: ask it to add a third pivot dimension (a "page" filter that slices which subset of data is shown), support sorting rows or columns by their total, or add CSV export of the rendered grid. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a pivot table that cross-tabulates a flat array of records in plain HTML, CSS, and vanilla JavaScript, with no charting or spreadsheet library.
Requirements:
- Start from a flat array of plain objects (for example, each with a region, a quarter, and a numeric sales value) and three configurable field names: which field becomes the row dimension, which becomes the column dimension, and which numeric field is being measured.
- Derive the unique values for the row dimension and the unique values for the column dimension directly from the data (not hardcoded), and build a table where every row/column intersection is a cell showing the aggregated measure for exactly the records matching both that row's value and that column's value.
- Implement a single aggregate function that supports at least three modes — sum, average, and count — and route every cell, every row total, every column total, and the grand total through that one function so switching modes can never leave some totals using a different calculation than others.
- Add a dropdown that lets the user switch the aggregation mode live, causing the entire grid (cells and all totals) to recompute and re-render immediately.
- Add a totals column at the right of each row and a totals row at the bottom of the table, plus a grand total in their intersection — and make sure every one of these totals is computed by aggregating the underlying raw records that match that slice, not by summing the already-rendered cell values, so the average mode's totals stay mathematically correct.
- Style numeric cells with right-aligned, tabular-figure text and header/label cells left-aligned, and make the table scroll horizontally if there are more columns than fit the container.Step by step
How to Use
- 1Paste HTML, CSS, and JSA pivot table renders, cross-tabulating sales by region (rows) and quarter (columns).
- 2Switch the measureUse the Measure selector to re-pivot as sum, average, or count.
- 3Read the totalsA Total column, Total row, and grand total summarise both axes.
- 4Change the dimensionsEdit the ROW, COL, and VAL field names to pivot by different fields.
- 5Swap in your dataReplace the flat DATA array with your own records.
- 6Make it interactiveWire ROW/COL/VAL to selectors to let users choose the pivot dimensions.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
A normal table shows one row per record. A pivot table cross-tabulates: it picks a field for the rows and a field for the columns, then summarises a measure at each intersection. This reshapes flat, one-row-per-event data into a compact summary grid — turning, say, twelve sales records into a region-by-quarter matrix with totals.
For additive measures like sum or count, summing the cells happens to give the right total. But for average, averaging the displayed cell-averages is mathematically wrong — it ignores how many records each cell represents. Aggregating the underlying raw records for every total guarantees correctness across all measures, which is essential for a trustworthy pivot.
Change the ROW, COL, and VAL constants to the field names you want as rows, columns, and the measured value. The code derives the unique values and aggregates generically, so any flat dataset pivots by any of its fields. To make it interactive, bind those constants to dropdowns and call render() on change.
Yes — the grid is generated from the unique values in your data, so it scales to any number of rows and columns, and the container scrolls horizontally when columns overflow. For very large datasets you'd precompute the aggregation once (e.g. group records into a map keyed by row+column) instead of filtering per cell, but the logic is the same.
In React, hold the data and measure in state and compute the pivoted structure with useMemo, then render the grid from it; in Vue, use a computed pivot object with v-for; in Angular, a getter with *ngFor. The uniq()/aggregate() cross-tab logic is framework-agnostic — only the measure state and rendering move into the framework.