Sticky Comparison Table Header and First Column

Comparison table with sticky table headers

This table was created using the Comparison Table Generator.

Sticky table column headers and row headers make it possible for users to maintain data cell context with minimal effort and cognitive load, but if you are a developer who works with tables a lot like me, you’ve probably encountered a common issue where you’ve given the table headers a position: sticky CSS rule, only to find that something is causing the table headers not to stick. It took me a few Google searches to discover that making table headers sticky will not work if a parent element has an overflow scroll or auto property, and it turns out that a lot of web builders will make the content surrounding code blocks overflow:scroll or overflow:auto.

“Why is this still a bug in CSS?” you might ask, to which I would respond I have no idea, but I have come across 2 tried-and-true solutions for this issue that will ensure table headers are sticky on all devices. One is a CSS-only solution, and the other is an optimized JS solution that I developed to address the few drawbacks of the widely-used CSS-only solution.

*Note that the example pictured above is an irregular class of table known as a Comparison Table that has one set of column table headers and another set of row headers, but the solutions offered in this tutorial will work for regular tables with only one set of table headers, too.

Option 1. CSS-Only Solution

Wrap the table element in a <div> that has “height” and “overflow” properties

Currently, the only universally excepted way of ensuring table headers will be sticky independent of the surrounding code when its container is overflowing, is to wrap the table element in a <div>, and give that <div> a height that is shorter than the window’s inner height and an overflow auto or scroll property. In my experience, a height that seems to work well on most devices is 80vh, which translates to 80 percent of the viewport’s height.

Note that if your table is much shorter than any device height, you do not need to give the table container a height, because in this scenario the container will not overflow.

Make the column header row, feature row headers, and mobile column group text sticky using “position: sticky”

Once you have wrapped the table in an <div> and styled it as previously described, we need to style the table headers with position:sticky, left:0, and z-index:10.

HTML

<div class="tableContainer">
  <table>
    <thead>
      <tr>
        <td></td>
        <th scope="col">Monday</th>
        <th scope="col">Tuesday</th>
        <th scope="col">Wednesday</th>
        <th scope="col">Thursday</th>
        <th scope="col">Friday</th>
        <th scope="col">Saturday</th>    
        <th scope="col">Sunday</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">09:00 – 11:00</th>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">11:00 – 13:00</th>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">13:00 – 15:00</th>
        <td>Open</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
      <tr>
        <th scope="row">15:00 – 17:00</th>
        <td>Closed</td>
        <td>Closed</td>
        <td>Closed</td>
        <td>Open</td>
        <td>Open</td>
        <td>Closed</td>
        <td>Closed</td>
      </tr>
    </tbody>
  </table>
</div>

CSS

.tableContainer {
  overflow: auto;
  height: 80vh;
}
table {
  font-family: arial, sans-serif;
  border-collapse: collapse;
  width: 100%;
}

td, th {
  border: 1px solid #dddddd;
  text-align: left;
  padding: 8px;
  min-width: 100px;
  background: #ffffff;
}
th[scope="row"], thead tr {
  position: sticky;
  top: 0;
  left: 0;
  z-index: 999;
}

Resulting Table w/ CSS Only

Option 2. Optimized JS Solution + Mobile-Friendly CSS

We’ve already covered how the only way to ensure that table headers will be sticky when one of its parents has an overflow scroll/auto property is to give the table container a height property using pure CSS that is shorter than the window height. However, this pure CSS solution still has drawbacks that can only be fixed using JS.

The problem with the CSS-only solution

The CSS-only solution solves the issue of table column headers not being sticky when the container is overflowing, but it introduces a usability concern that I only discovered when I performed some basic user testing.

Consider this scenario on mobile phones:

A user scrolls down the page and their thumb lands within the upper part of the table while it is still mostly hidden from the viewport. When the user moves their thumb upwards to continue scrolling, it seems as if the page is not moving. This is because when their thumb landed on the table, it actually started scrolling the table container - not the main HTML. This confuses the user because since most of the table is still hidden, they don’t realize that the table is actually scrolling.

I tested this out on 10 people I know, including my 6-year-old nephew who is considered a “Digital Native” (a person born or brought up during the age of digital technology and therefore familiar with computers from an early age). 7 of them encountered this exact scenario and couldn’t figure out why they couldn’t scroll the page.

How to solve this with JavaScript

I believe I have come up with a JS solution that addresses the previously mentioned issue. Here’s an explanation of the code:

When the document is scrolled, the code detects when the table head is within the top 50% of the document, at which point it will set the "pointer-events" property to "all", which enables table scrolling. When the table is below the top 60% of the page, scrolling is disabled. This makes sure at least half of the table's content is in the viewport before the user can start scrolling through it.

HTML

CSS

<div class="tableContainer">
  <table>
    <caption>
      This is a descriptive table caption.
    </caption>
    <thead>
      <tr class="columnHeaders">
        <th class="columnHeader emptyCell" title="Empty cell"></th>
        <th scope="col" class="columnHeader">Table header cell</th>
        <th scope="col" class="columnHeader">Table header cell</th>
        <th scope="col" class="columnHeader">Table header cell</th>
        <th scope="col" class="columnHeader">Table header cell</th>
      </tr>
    </thead>
    <tbody>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 1</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 1</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 2</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 2</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->     
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 3</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 3</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 4</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 4</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 5</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 5</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 6</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 6</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 7</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 7</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 8</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 8</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 9</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 9</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 10</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 10</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 11</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 11</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 12</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 12</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 13</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 13</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 14</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 14</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 15</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 15</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 16</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 16</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 17</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 17</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 18</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 18</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 19</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 19</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
      <!-- Start: This is the mobile view column group -->
      <tr class="mobileColumnGroup">
        <th scope="colgroup" colspan="4"><span>Mobile column group 20</span></th>
      </tr>
      <!-- End: This is the mobile view column group -->
      <tr class="tableBodyRow">
        <th scope="row" class="rowHeader">Desktop row header 20</th>
        <td>Table data cell</td>
        <td>
          <span title="Feature is included" class="featureCheck">✔</span>
        </td>
        <td>Table data cell</td>
        <td>
          <span title="Feature is not included" class="featureX">✘</span>
        </td>
      </tr>
    </tbody>
  </table>
  <div class="horizontal-scroller">
    <div class="horizontal-scroller-content"></div>
  </div>
</div>
@media(min-width: 769px) {
  .mobileColumnGroup {
    display: none;
  }
}
@media(max-width: 769px) {
  .rowHeader, .emptyCell {
    display: none;
  }
}
.tableContainer {
  overflow: auto;
  overflow-anchor: none;
  position: relative;
}
.tableContainer caption {
  height: 0px;
}
thead tr {
  position: sticky;
  top: 0;
  left: 0;
  z-index: 999;
}
.rowHeader, [scope="colgroup"] span, .emptyCell {
  left: 0;
  position: sticky;
}
table {
  font-family: arial, sans-serif;
  border-collapse: separate;
  border-spacing: 0;
  width: 100%;
  table-layout: auto;
}
td, th {
  border: 1px solid #dddddd;
  text-align: center;
  padding: 8px;
  min-width: 100px;
  max-width: 100px;
  background: #ffffff;
}
.mobileColumnGroup th {
    text-align: left;
}
.rowHeader {
  text-align: left;
}
.featureCheck {
  color: green;
}
.featureX {
  color: red;
}
thead.stickyHeader {
  position: fixed;
  z-index: 999;
  overflow-x: scroll;
  top: 0px;
}
thead.stickyHeader.atBottom {
  position: absolute;
  z-index: 999;
  bottom: 0px;
  left: 0px !important;
  top: initial;
  width: initial !important;
  overflow-x: clip;
}
thead::-webkit-scrollbar {
  height: 0px;
}
.horizontal-scroller {
  position: fixed;
  bottom: 0;
  height: 30px;
  overflow: auto;
  overflow-y: hidden;
}
.horizontal-scroller-content {
  height: 30px;
}

JavaScript

document.addEventListener("DOMContentLoaded", function() {
  try {
    var compTable = document.querySelector(".tableContainer");
    if (compTable) {
      var tableBodyRowData = compTable.querySelectorAll(".tableBodyRow td, .tableBodyRow th");
      var caption = compTable.querySelector("caption");
      var captionHeight = caption ? caption.offsetHeight : 0;
      var compTableBody = compTable.querySelector("tbody");
      var compTableHead = compTable.querySelector("thead");
      var columnHeaders = compTable.querySelectorAll(".columnHeader");
      var columnHeadersRow = compTableHead.querySelector(".columnHeaders");
      var columnHeadersNotEmpty = compTableHead.querySelectorAll(".columnHeader:not(.emptyCell)");
      var horizontalScroller = compTable.querySelector(".horizontal-scroller");
      var horizontalScrollerContent = horizontalScroller.querySelector(".horizontal-scroller-content");
      var originalTablePosition = "";

      function checkTableOffset() {
        var compTableRect = compTable.getBoundingClientRect();
        var compTableHeadRect = compTableHead.getBoundingClientRect();
        var compTableBodyRect = compTableBody.getBoundingClientRect();
        var compTableHeadRowRect = columnHeadersRow.getBoundingClientRect();
        horizontalScroller.style.left = compTableRect.left + "px";
        horizontalScroller.style.width = compTableRect.width + "px";
        horizontalScrollerContent.style.width = compTable.scrollWidth + "px";
        if (compTableRect.top <= 0 && compTableRect.height-50 > window.innerHeight) {
          calculateColumnHeaderWidthsAndPos(compTableRect, compTableHeadRect, compTableBodyRect);
        } else if (compTableBodyRect.top > compTableHeadRect.bottom) {
          resetTable();
        }

        if (compTableRect.bottom <= window.innerHeight || compTableRect.top > window.innerHeight) {
          horizontalScroller.style.visibility = "hidden";
        } else if (compTableRect.top <= window.innerHeight && compTable.clientWidth < compTable.scrollWidth) {
          horizontalScroller.style.visibility = "visible";
        }

        if (compTableRect.bottom <= compTableHeadRect.bottom && !Array.from(compTableHead.classList).includes("atBottom")) {
          compTableHead.classList.add("atBottom");
        } else if (compTableHeadRect.top >= 0) {
          compTableHead.classList.remove("atBottom");
        }
      }

      function scrollColumnHeader(compTable) {
        compTableHead.scrollLeft = compTable.scrollLeft;
      }

      function setColWidths(compTableRect) {
        compTableHead.style.width = compTableRect.width + "px";
        compTableHead.style.left = compTableRect.left + "px";
        for (let i = 0; i < columnHeaders.length; i++) {
          var tableBodyRowDataRect = tableBodyRowData[i].getBoundingClientRect();
          if (Array.from(compTableHead.classList).includes("stickyHeader")) {
            columnHeaders[i].style.minWidth = getComputedStyle(tableBodyRowData[i]).width;
          } else {
            columnHeaders[i].style.minWidth = "initial";
          }
        }
      }

      function calculateColumnHeaderWidthsAndPos(compTableRect, compTableHeadRect, compTableBodyRect) {
        compTableHead.classList.add("stickyHeader");
        scrollColumnHeader(compTable);
        setColWidths(compTableRect);

        var xMatrix = parseFloat(getComputedStyle(compTableBody).transform.substring(getComputedStyle(compTableBody).transform.indexOf("(")+1, getComputedStyle(compTableBody).transform.lastIndexOf(")")).split(",")[5]);
        if ((xMatrix == 0 || getComputedStyle(compTableBody).transform == 'none')) {
          compTableBody.style.transform = "translateY(" + (compTableHead.offsetHeight - captionHeight) + "px)";
          compTable.style.paddingBottom = (compTableHead.offsetHeight - captionHeight) + "px";
        }
      }

      function resetTable() {
        compTableHead.classList.remove("stickyHeader");
        compTableBody.style.transform = "translateY(0px)";
        compTable.style.paddingBottom = "0px";
      }

      window.addEventListener("scroll", function() {
        checkTableOffset();
      });

      window.addEventListener("resize", function () {
        resetTable();
        checkTableOffset();
      });

      compTable.addEventListener("scroll", function(e) {
        if (Array.from(compTableHead.classList).includes("stickyHeader") && !Array.from(compTableHead.classList).includes("atBottom")) {
          scrollColumnHeader(e.target);
        }
        horizontalScroller.scrollLeft = e.target.scrollLeft;
      });

      horizontalScroller.addEventListener("scroll", function(e) {
        if (Array.from(compTableHead.classList).includes("stickyHeader") && !Array.from(compTableHead.classList).includes("atBottom")) {
          scrollColumnHeader(e.target);
        }
        compTable.scrollLeft = e.target.scrollLeft;
      });

      compTableHead.addEventListener("scroll", function(e) {
        horizontalScroller.scrollLeft = e.target.scrollLeft;
        compTable.scrollLeft = e.target.scrollLeft;
      });

      var compTableRect = compTable.getBoundingClientRect();
      setColWidths(compTableRect);
      checkTableOffset();
    }
  } catch(error) {
    console.log(error);
  }
});

Resulting Table with CSS & JS

Caroline Smith

Caroline Smith is a solopreneur and front-end web developer with 5+ years of experience in web development.

https://launchhubstudio.com
Previous
Previous

2 Practical Uses for HTML Tables on Websites

Next
Next

How to Use Hex, RGB, and HSL Color Codes in Squarespace 7.1