More Tables Snippets
Grouped Rows Table — Collapsible Group HTML CSS JS
Grouped Rows Table · Tables · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Grouped Rows Table — Collapsible Category Groups with Subtotals & Grand Total

When a table's rows fall into natural categories — expenses by type, tasks by project, sales by region — grouping them with collapsible headers and subtotals turns a long flat list into a scannable, summarisable report. This snippet builds that grouped table in plain HTML, CSS, and vanilla JavaScript: rows clustered under category headers, each header showing a count and subtotal, individually collapsible with a caret, plus expand/collapse-all and a grand total — no library.
Grouping flat data into sections
The data is a flat ITEMS array where each row carries a group field. groupBy() folds that into an ordered map of group → rows, and the renderer emits a header row for each group followed by its item rows. Keeping the source flat (rather than pre-nested) is deliberate: it's the shape data actually arrives in from an API or database, and grouping in the view means you can regroup by a different field just by changing the key — without restructuring your data.
Headers that summarise
Each group header shows the category name, the number of items in it, and the group's subtotal (a reduce over its rows). Surfacing the subtotal on the header is what makes the grouping valuable — a reader can collapse every group and still see the totals per category, turning the table into a summary. A grand total in the table footer sums every row regardless of collapse state, so the bottom line is always correct.
Per-group and global collapse
Clicking any header toggles just that group: a caret rotates and the group's item rows get a hide class. The click is handled by one delegated listener on the table body via closest('.grt-group'), and rows are matched to their header by a shared data-group index — so collapsing is robust even as groups are added or reordered. A toolbar button collapses or expands *all* groups at once, flipping every caret and row together, which is the control people reach for on a table with many sections.
Indentation and alignment that read as a hierarchy
Item rows are indented under their header, amounts are right-aligned with tabular figures so they form a clean column, and the header subtotal floats to the same right edge — so subtotals and line items line up vertically into a proper financial-report layout. These alignment details are what make a grouped table read as a structured document rather than a striped list.
Data-driven and drop-in
Everything derives from the ITEMS array and its group field, so swapping in your data — or grouping by a different property — is a one-line change. Because it's dependency-free, it drops into any dashboard, expense report, or admin panel, and it's a clear reference for view-layer grouping, per-group aggregation, and delegated collapse handling.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You don't have to trace the delegated click handling by hand. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how the shared data-group index links a header row to its item rows through the closest lookup, or why groupBy folds a flat ITEMS array instead of expecting pre-nested data. The same assistant is useful for optimizing it — ask whether re-rendering the entire tbody via innerHTML on every toggle is wasteful for a table with hundreds of rows, and what a targeted class-toggle approach would look like instead. It is just as handy for extending the table: ask it to add per-group sorting, a search box that filters items while keeping group subtotals accurate, or persisted collapse state in localStorage. 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 table with collapsible grouped rows in plain HTML, CSS, and JavaScript — no library, no framework.
Requirements:
- Accept a flat array of row objects, each carrying a group field alongside its other fields (item name, vendor, amount).
- Write a groupBy function that folds the flat array into an ordered map of group name to its rows, preserving first-seen group order.
- Render one header row per group showing the group name, the count of items in it, and a subtotal computed with reduce over that group's rows, immediately followed by one row per item indented under it.
- Give each header row and its item rows a shared data-group index so they can be matched without relying on DOM adjacency.
- Use a single delegated click listener on the table body (not one listener per header) that finds the clicked header via closest, toggles an "open" class on it to rotate a CSS caret, and toggles a "hide" class on every item row sharing that data-group index.
- Add a toolbar button that collapses or expands every group at once by toggling the same classes on all headers and items in one pass.
- Add a table footer that always sums every row's amount into a grand total, regardless of which groups are currently collapsed.
- Right-align and use tabular-nums formatting for all monetary values so subtotals and the grand total form a clean vertical column.Step by step
How to Use
- 1Paste HTML, CSS, and JSAn expenses table renders with rows grouped under category headers showing counts and subtotals.
- 2Collapse a groupClick any category header to collapse or expand just that group; the caret rotates.
- 3Collapse or expand allUse the toolbar button to fold or unfold every group at once.
- 4Read the totalsEach header shows its subtotal and the footer shows the grand total across all rows.
- 5Swap in your dataReplace the ITEMS array; each row's group field determines its section.
- 6Regroup by another fieldChange groupBy to key on a different property to group by region, project, etc.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Data usually arrives flat from an API or database — a list of rows, each tagged with a category. groupBy() folds that flat list into sections at render time, which means you can regroup by a different field just by changing the key, with no change to your underlying data. Pre-nesting would lock you into one grouping and complicate updates.
One delegated click listener on the table body catches clicks on any header via closest('.grt-group'). It toggles an open class (which rotates the caret) and adds/removes a hide class on every item row sharing that header's data-group index. Matching rows by index rather than DOM position keeps collapsing correct even as groups are added or reordered.
No — collapsing is purely visual. The grand total in the footer is computed once from every row in the data, and each group's subtotal is computed from its own rows, both independent of collapse state. So you can fold every group and still read accurate per-category subtotals and a correct bottom line.
Change groupBy to key on the property you want — e.g. it.region or it.project instead of it.group — and update the data accordingly. The header rendering, subtotals, collapse logic, and grand total all work off whatever grouping the function produces, so switching the grouping dimension is a one-line change.
In React, group the data with useMemo and render headers/items from the grouped structure, tracking open groups in state; in Vue, use a computed grouped object with v-for and a reactive open map; in Angular, use a pipe or getter with *ngFor. The grouping and aggregation logic is framework-agnostic — only the collapse state and rendering move into the framework.