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.
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?
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.
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>