Engineering Journal
Table Formatter
Table Formatter

Building a Sticky Table Ruler with Synced Scroll in Vanilla JS

2026-05-14

TLDR

Applying position: 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 StrategyCSS Layout StructureDual-Axis Scroll ResultExport Cleanliness
Cell-Level position: stickySingle table containerJumps & clips during dual scrollRequires CSS class stripping
Separated Ruler ViewportsFlex containers + scrollLeft sync100% Smooth Dual-Axis TrackingClean 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.
Read this post in the full Engineering Journal →