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) |
Multi-axis sticky elements jump and clip during concurrent scroll operations
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.
Decoupling rulers into separate viewports and syncing scroll offsets eliminates jumping
We solved dual-axis tracking by decoupling the ruler UI into separate flex containers:
LAYOUT DECOUPLING ARCHITECTURE:
┌───────────┐┌──────────────────────────────┐ │ Origin ││ Column Ruler Strip (Flex) │ ◄── [Scroll Sync Left] │ (Fixed) ││ [A] [B] [C] [D] [E] [F] │ └───────────┘└──────────────────────────────┘ ┌───────────┐┌──────────────────────────────┐ │ Row Ruler ││ │ │ Index ││ Primary Grid Viewport │ │ (Flex) ││ [ ] [ ] [ ] [ ] [ ]│ │ [1] ││ │ │ [2] ││ │ ◄── [Master Scroll Area] │ [3] ││ │ │ [4] ││ │ └───────────┘└──────────────────────────────┘ ▲ │ [ResizeObserver Sync Heights]
Here is how we set up the layout synchronization:
// 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: Isolate row and column rulers into separate viewport containers, then use event-driven scroll mapping to synchronize offsets and eliminate sticky-jump artifacts.