Engineering Journal
Table Formatter
Table Formatter

Building a Sticky Table Ruler with Synced Scroll in Vanilla JS

2026-05-22

TLDR: The ruler is two sticky strips (top column numbers, left row numbers) plus a frozen scroll container that shares scrollLeft with the main table. The entire structure lives inside a CSS flex wrapper. No position: sticky on individual cells. No virtual scroll.

Repo: tools/table-formatter

What a Table Ruler Is

A spreadsheet ruler is the header strip that labels columns (A, B, C...) and rows (1, 2, 3...). It stays fixed while the table content scrolls. When you scroll right, the column labels scroll with the content. When you scroll down, the row labels scroll with the content.

Implementing this without a virtualized grid library turns out to have a few structural gotchas.


The Naive Attempt

First instinct: position: sticky on the first cell of each row and position: sticky on the header row.

This works for the first column and first row independently. It breaks when both are sticky at the same time. The corner cell (row 0, col 0) needs to stay fixed relative to both scroll axes simultaneously.

CSS sticky is relative to the scroll parent. If the table has one scroll parent for both axes, the corner cell cannot be sticky on both axes without the cell content jumping when one axis scrolls.


The Actual Structure

The solution is two separate scroll containers, not one.

.tafne-ruler-wrap          (flex column)
  .tafne-col-ruler         (sticky top strip, horizontal scroll only)
    .ruler-corner          (fixed top-left square)
    .ruler-cols            (scrollable column labels)
  .tafne-body-wrap         (flex row, fills remaining height)
    .tafne-row-ruler        (sticky left strip, static)
      .ruler-rows           (column of row number cells)
    .tafne-table-vp         (main scrollable viewport)
      table                 (the actual tafne table)

.tafne-table-vp is overflow: auto on both axes. It is the only element the user scrolls.

.tafne-col-ruler has overflow-x: hidden (labels don't scroll on their own) and is synced via JS.

.tafne-row-ruler is static. Row label cells are the same height as their corresponding table rows.


Scroll Sync

When .tafne-table-vp scrolls horizontally, .ruler-cols must mirror it.

tableVp.addEventListener('scroll', () => {
    rulerCols.scrollLeft = tableVp.scrollLeft;
});

That is the entire sync. rulerCols has overflow-x: hidden so the user cannot scroll it directly. The scroll position is only ever set programmatically from this listener.

Vertical row labels do not need JS sync. Each .ruler-row-cell is sized to match the corresponding <tr> height using a MutationObserver that fires whenever rows are added, removed, or resized:

const ro = new ResizeObserver(() => syncRowHeights());
ro.observe(tableEl);

function syncRowHeights() { const rows = tableEl.querySelectorAll('tr'); const cells = rulerRows.querySelectorAll('.ruler-row-cell'); rows.forEach((tr, i) => { if (cells[i]) cells[i].style.height = tr.offsetHeight + 'px'; }); }


Row Freeze

"Freeze rows" means the first N rows of the table stay visible while the rest scrolls. This is a separate concern from the ruler.

The implementation uses a nested scroll container. The frozen rows live outside .tafne-table-vp in a .tafne-frozen-rows element above it. The main viewport contains only the unfrozen rows.

When the user freezes row 2, rows 0 and 1 are moved from <table> into a separate <table> inside .tafne-frozen-rows. Both tables have identical column widths maintained via a shared <colgroup> width sync.

The column ruler already syncs scrollLeft from tableVp. The frozen rows table gets the same sync:

tableVp.addEventListener('scroll', () => {
    rulerCols.scrollLeft = tableVp.scrollLeft;
    frozenRowsTable.style.marginLeft = -tableVp.scrollLeft + 'px';
});

Using marginLeft instead of scrollLeft on the frozen table avoids creating a second scroll container that would need its own suppression logic.


Stripping the Ruler from Export

The ruler is a UI artifact. It must not appear in exported HTML, CSV, or clipboard content.

exportAsHtml() clones the table element, then removes ruler-related nodes from the clone before serializing:

function exportAsHtml() {
    const clone = tableEl.cloneNode(true);
    clone.querySelectorAll('.tafne-ruler-wrap, .ruler-corner').forEach(el => el.remove());
    return clone.outerHTML;
}

The ruler is also excluded from exportAsCsv() and copySelected() by operating on the data model (cellStore) rather than the DOM. Ruler cells are never in cellStore.


Tradeoffs

ResizeObserver fires frequently during drag resize. Syncing row heights on every resize event causes layout thrash. The fix is to debounce syncRowHeights by 16ms (one frame). Below 16ms, sync calls overlap and accumulate without visible benefit.

Column ruler width depends on table scroll width. If the table has many columns and the user adds more, the ruler strip must be wider than the viewport. The ruler is an overflow-hidden container, so new columns beyond the visible area are silently clipped. Widening the ruler to match table.scrollWidth on column add fixes this:

rulerCols.style.minWidth = tableEl.scrollWidth + 'px';

Frozen rows and the row ruler must stay height-synced. When rows freeze, the row ruler's cell count changes. syncRowHeights must re-run after any freeze/unfreeze operation, not just on resize.

Read this post in the full Engineering Journal →