csstailwind-css

Two elements with a shared parent scrollbar and sticky headers


I only included the image as an illustration of the look I want to achieve later, which is why I believe the desired result can't be done in a single shared table. That's why, by the end of the question, I split my code into two separate parents.

table gnatt illustration

If I have a single table, I can easily set up a sticky header, since I can limit the table's dimensions.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

<div class="max-h-screen h-fit w-1/4 overflow-x-auto">
  <div class="sticky top-0 h-10 w-[2000px] bg-green-500 text-white z-10">Sticky Table Header</div>
  <div>
    <div class="sticky top-10 w-[2000px] bg-green-300">Sticky Group Header #1</div>
    <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    <div class="h-40 w-[2000px] bg-gray-100">Row</div>
  </div>
  <div>
    <div class="sticky top-10 w-[2000px] bg-green-300">Sticky Group Header #2</div>
    <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    <div class="h-40 w-[2000px] bg-gray-100">Row</div>
  </div>
</div>

If I place two tables inside the parent container, I can't make each table's dimensions too small, because then each one will get its own scrollbar - and I want the whole thing to scroll together on the Y-axis.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

<div class="flex h-screen w-screen overflow-y-auto divide-x-2">
  <!-- without max-h-screen -->
  <div class="h-fit w-1/4 overflow-x-auto">
    <div class="sticky top-0 h-10 w-[2000px] bg-green-500 text-white z-10">Sticky Table Header</div>
    <div>
      <div class="sticky top-10 w-[2000px] bg-green-300">Sticky Group Header #1</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    </div>
    <div>
      <div class="sticky top-10 w-[2000px] bg-green-300">Sticky Group Header #2</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    </div>
  </div>
  <!-- without max-h-screen -->
  <div class="h-fit w-3/4 overflow-x-auto">
    <div class="h-10 sticky top-0 w-[2000px] bg-amber-500 text-white z-10">Sticky Gantt Header</div>
    <div>
      <div class="sticky top-10 w-[2000px] bg-amber-300">Sticky Group Header #1</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    </div>
    <div>
      <div class="sticky top-10 w-[2000px] bg-amber-300">Sticky Group Header #2</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    </div>
  </div>
</div>

In this case, however, sticky doesn't work as expected, because the table height is not limited, and therefore no actual scrolling happens inside the table itself.

If I maximize my table's height, the sticky works correctly because I'm actually scrolling the sticky's parent. However, this way I can't scroll the two elements together as one.

<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>

<div class="flex h-screen w-screen overflow-y-auto divide-x-2">
  <!-- with max-h-screen -->
  <div class="max-h-screen h-fit w-1/4 overflow-x-auto">
    <div class="sticky top-0 h-10 w-[2000px] bg-green-500 text-white z-10">Sticky Table Header</div>
    <div>
      <div class="sticky top-10 w-[2000px] bg-green-300">Sticky Group Header #1</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    </div>
    <div>
      <div class="sticky top-10 w-[2000px] bg-green-300">Sticky Group Header #2</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    </div>
  </div>
  <!-- with max-h-screen -->
  <div class="max-h-screen h-fit w-3/4 overflow-x-auto">
    <div class="h-10 sticky top-0 w-[2000px] bg-amber-500 text-white z-10">Sticky Gantt Header</div>
    <div>
      <div class="sticky top-10 w-[2000px] bg-amber-300">Sticky Group Header #1</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    </div>
    <div>
      <div class="sticky top-10 w-[2000px] bg-amber-300">Sticky Group Header #2</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
      <div class="h-40 w-[2000px] bg-gray-100">Row</div>
    </div>
  </div>
</div>

How can I scroll the two tables in sync while keeping the sticky header?


Solution

  • Sticky aligns itself to the nearest scrollable parent, but in our case, you haven't reduced the table's height, so the shared parent element only appears scrollable — it's only there to let us reach the bottom of the non-scrollable tables.

    Solution with JavaScript

    Using JavaScript with a few event listeners, you can easily achieve the desired result. For the CSS to work properly, I would set a maximum height for the two table panels so that the sticky position works as expected relative to each panel.

    After that, I would hide the scrollbars of the two panels. They would no longer be directly scrollable, but their scroll events could still be listened to.

    I would then insert a third, invisible panel with the same height as the two panels. This invisible panel would be responsible for displaying the Y-axis scrollbar.

    Now the task is to synchronize the scroll state of all three panels. When you move the scrollbar of the third panel, both tables should move together. If a scroll event occurs on either of the original two panels, it should visually appear as if you are scrolling the third panel.

    To achieve this, you need to set up event listeners on all three panels.

    // DOM element references
    const leftPanel = document.getElementById('myLeftPanel');
    const rightPanel = document.getElementById('myRightPanel');
    const masterScroll = document.getElementById('masterScroll');
    const scrollContent = document.getElementById('scrollContent');
    
    let contentHeight = 0;
    let isScrolling = false;
    
    // Calculate and set content height for master scroll
    function calculateContentHeight() {
      if (leftPanel) {
        contentHeight = leftPanel.scrollHeight;
        scrollContent.style.height = `${contentHeight}px`;
      }
    }
    
    // Wheel event handler
    function handleWheel(e) {
      // If you don't need other events, you can disable them
      // e.preventDefault();
      
      // If you don't disable them, you'll need to handle special cases such as SHIFT+scroll, which scrolls along the X-axis but also triggers the wheel scroll event
      if (e.shiftKey) {
        return;
      }
    
      if (masterScroll && !isScrolling) {
        const delta = e.deltaY;
        const currentScroll = masterScroll.scrollTop;
        const maxScroll = masterScroll.scrollHeight - masterScroll.clientHeight;
        const newScroll = Math.max(0, Math.min(maxScroll, currentScroll + delta));
    
        // Modify the master scroll directly, which automatically synchronizes the panels
        masterScroll.scrollTop = newScroll;
      }
    }
    
    // Master scroll event handler
    function handleMasterScroll(e) {
      if (isScrolling) return;
    
      isScrolling = true;
      const scrollTop = e.target.scrollTop;
    
      if (leftPanel) {
        leftPanel.scrollTop = scrollTop;
      }
      if (rightPanel) {
        rightPanel.scrollTop = scrollTop;
      }
    
      requestAnimationFrame(() => {
        isScrolling = false;
      });
    }
    
    // Add event listeners
    function addEventListeners() {
      // Add wheel event listeners to both panels
      if (leftPanel) {
        leftPanel.addEventListener('wheel', handleWheel, { passive: false });
      }
      if (rightPanel) {
        rightPanel.addEventListener('wheel', handleWheel, { passive: false });
      }
    
      // Master scroll event listener
      if (masterScroll) {
        masterScroll.addEventListener('scroll', handleMasterScroll);
      }
    }
    
    // Remove event listeners (cleanup)
    function removeEventListeners() {
      if (leftPanel) {
        leftPanel.removeEventListener('wheel', handleWheel);
      }
      if (rightPanel) {
        rightPanel.removeEventListener('wheel', handleWheel);
      }
      if (masterScroll) {
        masterScroll.removeEventListener('scroll', handleMasterScroll);
      }
    }
    
    // Initialize
    function init() {
      calculateContentHeight();
      addEventListeners();
    }
    
    // Initialize after DOM content loaded
    document.addEventListener('DOMContentLoaded', init);
    
    // Cleanup when leaving the page
    window.addEventListener('beforeunload', removeEventListeners);
    <script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
    
    <div class="flex h-screen w-screen overflow-y-auto divide-x-2">
      <!-- with max-h-screen -->
      <div id="myLeftPanel" class="max-h-full h-fit w-1/4 overflow-x-auto overflow-y-hidden ">
        <div class="sticky top-0 h-10 w-[2000px] bg-green-500 text-white z-10">Sticky Table Header</div>
        <div>
          <div class="sticky top-10 w-[2000px] bg-green-300">Sticky Group Header #1</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
        </div>
        <div>
          <div class="sticky top-10 w-[2000px] bg-green-300">Sticky Group Header #2</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
        </div>
        <div>
          <div class="sticky top-10 w-[2000px] bg-green-300">Sticky Group Header #3</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
        </div>
      </div>
      <!-- with max-h-full overflow-y-hidden -->
      <div id="myRightPanel" class="max-h-full h-fit w-3/4 overflow-x-auto overflow-y-hidden">
        <div class="h-10 sticky top-0 w-[2000px] bg-amber-500 text-white z-10">Sticky Gantt Header</div>
        <div>
          <div class="sticky top-10 w-[2000px] bg-amber-300">Sticky Group Header #1</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
        </div>
        <div>
          <div class="sticky top-10 w-[2000px] bg-amber-300">Sticky Group Header #2</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
        </div>
        <div>
          <div class="sticky top-10 w-[2000px] bg-amber-300">Sticky Group Header #3</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
          <div class="h-20 w-[2000px] bg-gray-100 even:bg-gray-200">Row</div>
        </div>
      </div>
      
      <!-- Master scroll item for visible Y scrollbar (is above by z-10 and receives wheel events) -->
      <div id="masterScroll" class="fixed inset-0 overflow-y-auto overflow-x-hidden z-10">
        <!-- Added for scrollbar visibility and correctly height -->
        <div id="scrollContent" style="width: 1px;"></div>
      </div>
    </div>