Building a Sticky Table Ruler with Synced Scroll in Vanilla JS
TLDR
Applyingposition: sticky simultaneously across both horizontal and vertical table headers causes positioning artifacts when scrolling along multiple axes. Separating the layout into dedicated overflow containers (a top column ruler strip, a left row ruler strip, and a primary table viewport) synced via scrollLeft listeners provides smooth multi-axis ruler tracking.
| Sticky Header Strategy | CSS Layout Structure | Dual-Axis Scroll Result | Export Cleanliness |
|---|---|---|---|
Cell-Level position: sticky | Single table container | Jumps & clips during dual scroll | Requires CSS class stripping |
| Separated Ruler Viewports | Flex containers + scrollLeft sync | 100% Smooth Dual-Axis Tracking | Clean Data Export (DOM isolated) |
Why dual-axis sticky positioning causes layout jumping
In spreadsheet applications, column headers (A, B, C...) and row indices (1, 2, 3...) must remain visible while users scroll across large data grids.
Applying position: sticky to <th> elements works well along a single axis. However, when scrolling horizontally and vertically at the same time, the top-left origin cell jumps because browsers calculate sticky offsets relative to a shared scroll parent.
Separating scroll viewports and synchronizing horizontal offsets
We solved dual-axis tracking by decoupling the ruler UI into separate flex containers:
// Synchronize horizontal scroll offset from primary table viewport to column ruler
export function bindTableRulerScroll(tableViewportEl, colRulerStripEl) {
tableViewportEl.addEventListener('scroll', () => {
// Mirror horizontal scroll offset without direct user interaction
colRulerStripEl.scrollLeft = tableViewportEl.scrollLeft;
});
}
// Synchronize row heights dynamically via ResizeObserver export function observeRowHeights(tableEl, rowRulerStripEl) { const resizeObserver = new ResizeObserver(() => { const tableRows = tableEl.querySelectorAll('tr'); const rulerCells = rowRulerStripEl.querySelectorAll('.ruler-row-cell');
tableRows.forEach((tr, idx) => { if (rulerCells[idx]) { rulerCells[idx].style.height = ${tr.offsetHeight}px; } }); });
resizeObserver.observe(tableEl); }
Because ruler strips live outside the core <table> node, DOM export generators extract table data cleanly without requiring ruler element removal routines.
Rule of thumb: Decouple dual-axis spreadsheet rulers into dedicated overflow containers synchronized via scrollLeft listeners.