webdevpuneet.com

Grouped Column Headers Table — Two-Level colspan Header (HTML CSS JS)

Grouped Column Headers Table · Tables · Plain HTML, CSS & JS · Live preview

CategoryTables

Copy & paste

Source Code

<div class="cgh-wrap">
  <div class="cgh-bar">
    <h3>Quarterly revenue by region</h3>
    <span class="cgh-note">Figures in $000s</span>
  </div>
  <div class="cgh-scroll">
    <table class="cgh-table">
      <thead>
        <tr>
          <th class="cgh-corner" rowspan="2">Region</th>
          <th class="cgh-group cgh-q1" colspan="3">Q1</th>
          <th class="cgh-group cgh-q2" colspan="3">Q2</th>
          <th class="cgh-total-head" rowspan="2">H1 Total</th>
        </tr>
        <tr>
          <th class="cgh-q1">Jan</th><th class="cgh-q1">Feb</th><th class="cgh-q1">Mar</th>
          <th class="cgh-q2">Apr</th><th class="cgh-q2">May</th><th class="cgh-q2">Jun</th>
        </tr>
      </thead>
      <tbody id="cghBody"></tbody>
    </table>
  </div>
</div>

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.

· Become a supporter

What's included

Features

Real colspan grouping
Group headers use actual colspan="3" over month <th> cells — no positioned overlay.
rowspan corner cells
Region and Total headers span both header rows once via rowspan="2".
Browser-resolved alignment
Native table layout keeps groups aligned to columns at any width.
Screen-reader correct
Real header/data cell relationships, unlike a CSS-only faked header bar.
Copy-paste fidelity
Selecting and pasting into a spreadsheet preserves the grouped structure.
Color-coded groups
Each quarter's header and data columns share a tint for fast visual grouping.
Row and column totals
An H1 Total column sums each region's six months.
Data-driven & no library
Renders from a flat DATA array and a MONTHS key list — zero dependencies.

About this UI Snippet

Grouped Column Headers Table — Real colspan/rowspan for Multi-Level Headers

Screenshot of the Grouped Column Headers Table snippet rendered live

Wide tables with many related columns — months under quarters, metrics under categories — read better when a top header row groups them, and a second row labels each individual column. This snippet builds that grouped header the correct way: real colspan and rowspan attributes on actual <th> elements, not a second absolutely-positioned bar drawn over a flat header. The result is a table that's both visually grouped and structurally correct for screen readers and copy-paste into a spreadsheet.

Two real header rows, not one faked row

The <thead> contains two <tr> elements. The first row has a "Region" corner cell with rowspan="2" (so it spans both header rows without repeating), then a "Q1" <th colspan="3"> and a "Q2" <th colspan="3">, each covering exactly three of the six data columns, plus an "H1 Total" corner with rowspan="2". The second row supplies the six individual month labels. Because the browser's own table layout engine resolves the colspans, the group headers stay perfectly aligned with their columns at any width or font size — a visual-only overlay would drift the moment content reflows.

Why rowspan matters for the corner cells

The "Region" and "H1 Total" headers logically belong to *both* header rows (a single column, but the header block is two rows tall), so they're written once with rowspan="2" rather than duplicated. This is the detail that trips people up when hand-building grouped headers: without rowspan, the corner cells either repeat awkwardly or the columns miscount, because the browser expects every row in a table to describe the same number of column slots once spans are accounted for.

Semantically correct, not just visually grouped

Because this uses genuine table markup, a screen reader announcing a cell can trace it back through its header row to both "Q1" and "Feb," and selecting the table and pasting into Excel or Sheets reproduces the same grouped structure. A CSS-only fake header (a positioned bar with no real <th> relationship to the columns below it) loses all of that — it looks right but conveys nothing structurally and breaks on copy-paste or with assistive technology.

Color-coded groups for fast scanning

Each quarter's header cells and its underlying data columns share a tint (indigo for Q1, green for Q2), so a reader's eye groups the six month columns into two chunks before reading a single number — useful once you have more than three or four related columns in a row.

Data-driven and generic

The body renders from a flat DATA array of region records; the MONTHS array drives which keys become columns and their total. Add a new quarter by adding a third grouped <th colspan="3"> and three more month keys — the rendering loop over MONTHS needs no other change. Pair this pattern with a pivot table when you need the grouping computed from raw records rather than pre-aggregated columns, or a sticky header table to keep a grouped header visible while scrolling a long report.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA revenue table renders with Q1/Q2 group headers spanning three month columns each.
  2. 2
    Inspect the header markupTwo real <tr> rows in <thead>, with colspan="3" on each quarter and rowspan="2" on the corner cells.
  3. 3
    Resize the panelGroup headers stay aligned to their columns because the browser's own table layout resolves the spans.
  4. 4
    Add a quarterAdd a third colspan="3" group header and three more month keys to MONTHS and each DATA row.
  5. 5
    Select and copySelect the table and paste into a spreadsheet — the grouped structure carries over.
  6. 6
    Swap in your dataReplace DATA and MONTHS with your own metrics and periods.

Real-world uses

Common Use Cases

Financial and revenue reports
Group months under quarters or quarters under years — pair with a pivot table for aggregated views.
Multi-metric comparisons
Group related metrics (min/avg/max) under one category header, similar to a comparison table.
Scientific and survey data
Group repeated measures (pre/post) under a condition header.
Scheduling grids
Group time slots under a day header in a schedule table-style layout.
Long reports needing sticky headers
Combine with a sticky header table so the grouped header stays visible on scroll.
Learning table semantics
A reference for correct colspan/rowspan grouping — compare with a grouped rows table, which groups rows instead of columns.
Related: Inline Cell Bulk-Edit Table — Floating Save Bar
See the Inline Cell Bulk-Edit Table — Floating Save Bar for a related tables pattern worth pairing with this one.

Got questions?

Frequently Asked Questions

A positioned overlay only looks aligned — it has no real relationship to the columns beneath it, so it drifts on resize, breaks with different font sizes, and conveys nothing to screen readers or spreadsheet paste. Real colspan is resolved by the browser's own table layout engine, so the group header is always exactly as wide as the columns it spans, at any viewport width.

Those two headers apply to a single column but the header block is two rows tall. rowspan="2" lets one cell occupy both header rows for that column, rather than duplicating the label or leaving an empty cell. Without it, the browser would miscount the columns in the second header row relative to the first.

Yes — because the groups are real <th> elements with genuine colspan/rowspan relationships to the data cells below, assistive technology can associate each data cell with both its group header (e.g. "Q1") and its specific column header (e.g. "Feb"), which a CSS-only faked header cannot provide.

Add a new <tr> above the current first row with a single <th colspan="6"> (or however many columns the year spans) for the year label, using rowspan="1" since it's a new top row, and increase the rowspan on the Region and Total corner cells to 3 so they still span all three header rows.

Render the two <thead> rows from a config object describing each group's label and column count, and the body from your data array as here. The colspan/rowspan markup is framework-agnostic — only the loop that emits <th> elements moves into JSX/template syntax.