Fixing Sticky Table Header with Horizontal Scroll in a Scrollable Container

Problem description:

I have a layout where a fixed menu occupies the left side of the viewport, taking up the full height. On the right side, I have a table that dynamically loads data as you scroll. The table includes both a header and a footer.

The issue arises with the table header:

The header stays sticky at the top when scrolling vertically. However, when I set overflow-x: auto on the .content container to allow horizontal scrolling, the sticky behavior of the header breaks, and it no longer remains fixed at the top.

What I tried:

I applied position: sticky; top: 0; to the thead to keep it fixed. I wrapped the table inside a div with overflow-x: auto, but this causes the sticky behavior to stop working. I also tried setting position: sticky directly on the th element, but that didn’t help. I attempted to use display: block; on the table header, but it affected the column alignment.

What I expected:

I want the table header to remain sticky at the top of the viewport when scrolling vertically, while also allowing horizontal scrolling within the .content div.

let rowCount = 20;
const tbody = document.querySelector('#dataTable tbody');

// Function to generate a table row (simulate fetched data)
function generateRow(index) {
  return `
    <tr>
      <td>Row ${index} - Fixed</td>
      <td>Row ${index} - Col2</td>
      <td>Row ${index} - Col3</td>
      <td>Row ${index} - Col4</td>
      <td>Row ${index} - Col5</td>
      <td>Row ${index} - Col6</td>
      <td>Row ${index} - Col7</td>
      <td>Row ${index} - Col8</td>
      <td>Row ${index} - Col9</td>
      <td>Row ${index} - Col10</td>
    </tr>
  `;
}

// Insert initial rows
for (let i = 1; i <= rowCount; i++) {
  tbody.insertAdjacentHTML('beforeend', generateRow(i));
}

// Auto-load more rows when scrolling near the bottom of the page
let loading = false;
window.addEventListener('scroll', function() {
  if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 100 && !loading) {
    loading = true;
    // Simulate API call delay (replace with your actual API call)
    setTimeout(() => {
      for (let i = rowCount + 1; i <= rowCount + 10; i++) {
        tbody.insertAdjacentHTML('beforeend', generateRow(i));
      }
      rowCount += 10;
      loading = false;
    }, 300);
  }
});
.container {
  max-width: 1350px;
  padding: 0;

  @media (max-width: 1350px) {
    padding: 0 15px;
  }

  @media (max-width: 991.98px) {
    padding: 0 15px;
  }
}

.d-flex {
  display: flex !important;
}

.align-items-start {
  align-items: flex-start !important;
}

.gap-30 {
  gap: 30px;
}

.menu-main-wrapper {
  background: linear-gradient(93.39deg, rgba(113, 113, 113, 0.62) 0.08%, rgba(113, 113, 113, 0) 79.29%);
  padding: 0 1px;
  height: 100svh;
  width: 25.25%;
  position: -webkit-sticky;
  position: sticky;
  z-index: 10;
  top: 0;
}

@media screen and (max-width: 1133.98px) {
  .menu-main-wrapper {
    display: none;
  }
}

.page-main-wrapper {
  width: 74.75%;
}

@media screen and (max-width: 1133px) {
  .page-main-wrapper {
    width: 100%;
  }
}

.content {
  width: 100%;
}

.table-wrapper {
  width: 100%;
}


/* Table styling */
table {
  border-collapse: separate;
  border-spacing: 0;
  width: 100%;
  min-width: 100px;
  margin-bottom: 20px;
}

th,
td {
  padding: 10px;
  white-space: nowrap;
  border-bottom: 1px solid green;
}

thead th {
  position: sticky;
  top: 0;
  background: #686161;
  z-index: 2;
}

th:first-child,
td:first-child {
  position: sticky;
  left: 0;
  background: #7e4545;
  z-index: 1;
  border-right: 1px solid blue;
}

thead th:first-child {
  z-index: 3;
  border-top-left-radius: 10px;
  border-top: 1px solid red;
}

.header,
.footer {
  height: 100px;
  background: blue;
  text-align: center;
  margin: 30px 0;
}
<div class="container">
  <div class="d-flex align-items-start gap-30">
    <div class="menu-main-wrapper">
    </div>
    <div class="page-main-wrapper">
      <div class="header">header</div>
      <div class="content">
        <div class="table-wrapper">
          <table id="dataTable">
            <thead>
              <tr>
                <th>Fixed Column</th>
                <th>Column 2</th>
                <th>Column 3</th>
                <th>Column 4</th>
                <th>Column 5</th>
                <th>Column 6</th>
                <th>Column 7</th>
                <th>Column 8</th>
                <th>Column 9</th>
                <th>Column 10</th>
              </tr>
            </thead>
            <tbody>
            </tbody>
          </table>
        </div>
      </div>
      <div class="footer">
      </div>
    </div>
  </div>

hello and welcome to fcc forum :slight_smile:

for scenarios like these its always better to include some live link with these codes in platform such as “repl/codepen/etc”, then it become more visual and easier to offer any help where possible

happy coding :slight_smile:

ok, thanks.
codepen link - https://codepen.io/Amit-Soni-the-vuer/pen/mydeqVE

thats nice, thanks :slight_smile:

have you tried using “position: fixed” for that “header”

i tried to tinker with a bit it cam eto look something like this, it stays fixed but not exactly where i wanted, i suppose that might be due to “table wrapper/other container” styles

happy coding :slight_smile:

fixed vs sticky

You can make your code have a fixed or sticky position and it should still work. I would rather use sticky because if your element is in the document flow the other elements make room for it (this does not account for horizontal scroll bars). This compaired to fixed where your element, being outside the document can overlap or cover other elements and you have to micromanage or constantly adjust your the position of all the elements.

fixed

The element is positioned relative to the viewport or browser window and the element will be outside the document flow

sticky

The element is positioned relative to the parent element and the element will stay in your document flow

One possible solution is to modify your .table-wrapper in your CSS file by:

  • removing the 100% width
    • you don’t need it
  • setting overflow to auto
    • allows you to scroll
  • adding a height
    • allows you to scroll vertically. if you don’t have this set your table will keep pushing down the scroll bar when you scroll down because you are generating more rows dynamically with your js code.

*Edited for clarity, the steps are still the same.