npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

dom-grid

v0.4.0

Published

Table geometry engine: synchronous layout, scrolling, pinned columns, resizing and virtualization over your own markup.

Readme

dom-grid

Table geometry engine: synchronous layout, scrolling, pinned columns, resizing and virtualization over markup you own.

Why

A table has two kinds of change, and they want opposite things.

Structure changes rarely: which columns exist, how many rows there are, what sits in a cell. Declarative rendering fits it perfectly.

Geometry changes every frame: offsets while scrolling, a column edge following the pointer, the window of rows that has to exist. Here a framework update lands a frame late, and the header visibly trails the body.

dom-grid takes the second half. It writes to the DOM directly and synchronously, within the same event, so nothing ever drifts apart by a frame. The markup stays yours: the engine never queries the DOM, it only positions nodes handed to it.

Install

npm i dom-grid

Core (no framework)

import { createGrid } from 'dom-grid'

const grid = createGrid({
  root, body, headerRow, verticalScrollbar, horizontalScrollbar,
  columns: [
    { key: 'id', width: 80, pinned: 'left' },
    { key: 'name' },                       // 'auto' shares the leftover space
    { key: 'state', width: 100, pinned: 'right' },
  ],
  rowHeight: 28,
  rowCount: 5_000,
  onRangeChange: ({ start, end }) => renderRows(start, end),
})

grid.registerHeaderCell(element, 'name')   // the view hands nodes over as it creates them
grid.registerRow(element, rowIndex)
grid.registerCell(element, rowIndex, 'name')

An element holds exactly one place: registering a recycled node under a new index drops its previous record immediately, so a node that comes back to an index it held before is never positioned by a stale entry.

| Method | What it does | |---|---| | registerHeaderCell / registerRow / registerCell | hand a node over, or pass null to drop it | | setColumns / setRowCount | structure changed | | setRowHeightSource(source) | swap the height source, a number or a function of the index | | setRowHeight(index, height) / clearRowHeight(index) | override one row after measuring it, or return it to the source height | | setPinnedLayers(left?, right?) | the pinned strips came or went | | startColumnResize(key, event) | begin a drag on a column edge | | startRowDrag(index, event) | begin dragging a row, reported through onRowDrop | | scrollToRow(index) | move the viewport | | getColumnWidths() | current widths, for persisting them | | getResizedWidths() / setResizedWidths() | only the hand-resized ones, to save and restore | | resetColumnWidths(key?) | drop hand-resized widths | | rowOffset(index) / rowHeight(index) | where a row sits and how tall it is | | layout / range / contentWidth / contentHeight / scrollPosition | current geometry | | destroy() | detach every listener |

Pure helpers are exported too, so the maths can be used on its own: computeLayout(columns, availableWidth), computeRange(...) and RowMetrics for variable row offsets.

Scroll modes

scrollMode decides who moves the content.

  • 'overlay' (the default): the caller supplies two thin scrollbar strips, and the engine moves the content itself with a transform on the body. Wheel and touch are handled on the root.
  • 'native': rows live inside a real scrolling container, so the browser draws its own scrollbars and handles the wheel. The engine then places rows at their absolute offsets instead of compensating for the scroll, and a scroll event rewrites only what really follows it: a header outside the scroller, strips the browser does not move, a row a drag holds, and pinned cells that have no strip of their own. Wheel handling stays with the browser, so wheel is ignored here.

In native mode the same element is both the vertical and the horizontal scroller, and it is the element columns are measured in. The Vue adapter wires that up on its own: leave hScrollRef unused and it stands in for both, and the viewport becomes the scroller rather than the body.

Sorting and selection

The engine does not touch your data, so it tracks state rather than doing the work.

import { SelectionModel, SortState } from 'dom-grid'

const sort = new SortState()
sort.toggle('name')            // asc -> desc -> unsorted on repeated clicks
sort.value                     // [{ key: 'name', direction: 'asc' }], click order is priority
sort.toggle('city', true)      // shift-click: adds the column, keeps 'name' sorted
sort.directionOf('name')       // 'asc' | 'desc' | null
sort.priorityOf('name')        // 1 for the first sorted column, null when unsorted

const selection = new SelectionModel({ isDisabled: (id) => locked.has(id) })
selection.toggle(id)
selection.selectRange(id, visibleIds)   // shift-click, ids in the order shown
selection.allSelected(visibleIds)

toggle(key, additive) behaves differently on the two paths, and the difference is deliberate:

  • a plain toggle replaces the whole sort with this one column and cycles it through ascending, descending and unsorted;
  • an additive toggle appends the column, ascending, when it was not sorted, and otherwise only flips its direction. It never drops the column: doing so would make the one way to leave a multi-column sort the same gesture as the one way to build it. Use clear(), or set([]), to end it.

multiple in SortOptions only changes the default of additive, so with multiple: true an ordinary click already adds a column instead of replacing the sort. Priority follows the order columns were added, not the latest click.

Selection works with row ids rather than indices, so it survives sorting and filtering. In Vue both come as useSort() and useSelection() with the same API, wrapped in refs, plus a set() on each for replacing the whole state when it is owned from the outside:

const sort = useSort({ multiple: true })
sort.set(savedEntries)                 // SortEntry[], as stored in user settings

const selection = useSelection()
selection.set(props.modelValue)        // RowId[], disabled rows dropped

Grouped headers

A header may be a tree: groups covering columns, columns covering nothing. Widths belong to the engine, tiers belong to whoever draws the header, and layoutHeader is where the two meet.

import { columnOffsets, headerDepth, type HeaderNode, headerLeaves, layoutHeader } from 'dom-grid'

const tree: HeaderNode[] = [
  { key: 'id' },
  { children: [{ key: 'city' }, { key: 'street' }] },
  { key: 'state', hidden: true },
]

headerLeaves(tree)                 // the columns, in display order: id, city, street
headerDepth(tree)                  // 2 tiers
columnOffsets(widths, ['id', 'city', 'street'])   // left edge of each column

const header = layoutHeader(tree, widths)

widths is Record<ColumnKey, number>, the resolved widths the engine reports. The result is { depth, cells, handles }:

  • depth is how many tiers the header needs;
  • each cell in cells carries key, the caller's own node, isLeaf, depth (the tier it starts on, 0 at the top), rowSpan (a leaf under a shallow branch stretches down to the bottom tier), left, width and leafKeys, the columns it stands over;
  • each handle in handles is a grip on the right edge of one column: key, node, edge (the boundary itself) and depth, the topmost tier the grip may start on. A boundary inside a group splits that group's columns and not the group itself, so a grip drawn full height would cut the group in two; it starts below every cell it would have cut.

Only leaves are positioned by the engine, since only they are columns: register them with registerHeaderCell and they follow the scroll and a resize drag on their own. A group has no column, so nothing knows its node, and the caller places it from the left and width of its box, recomputing on onLayoutChange. Groups are hidden with hidden: true, and a children array that ends up empty takes the group with it.

The third argument is the display order of every column, service ones the tree knows nothing about included, so a checkbox column in front of the tree still shifts the groups behind it:

layoutHeader(tree, widths, ['select', ...headerLeaves(tree).map((node) => node.key!)])

Vue

<script setup lang="ts">
import { useGrid } from 'dom-grid/vue'

const {
  rootRef, bodyRef, headerRef, vScrollRef, hScrollRef,
  visibleRows, contentWidth, contentHeight,
  columnWidths, columnZones,
  registerHeaderCell, registerRow, registerCell, grid,
} = useGrid({
  columns: () => props.columns,
  rows: () => props.data,
  rowHeight: () => 28,
})
</script>

<template>
  <div ref="rootRef" class="grid">
    <div ref="headerRef">
      <div v-for="col in columns" :key="col.key" :ref="(el) => registerHeaderCell(el, col.key)">
        {{ col.key }}
      </div>
    </div>

    <div ref="bodyRef">
      <div v-for="row in visibleRows" :key="row.poolId" :ref="(el) => registerRow(el, row.index)">
        <div v-for="col in columns" :key="col.key" :ref="(el) => registerCell(el, row.index, col.key)">
          {{ row.data[col.key] }}
        </div>
      </div>
    </div>

    <div ref="vScrollRef"><div :style="{ height: `${contentHeight}px` }" /></div>
    <div ref="hScrollRef"><div :style="{ width: `${contentWidth}px` }" /></div>
  </div>
</template>

Vue decides which rows and columns exist; the engine places them. Nodes travel through ref callbacks, so a re-render never leaves stale positions behind.

Key rows by row.poolId, not by the row id. The pool id is the row's slot in a recycling pool, so the same DOM nodes stay alive while the window moves and the framework only patches their content. Keying by data id instead destroys and rebuilds every row on each scroll, which is what makes fast scrolling flicker.

What useGrid returns

Besides the refs and the register functions:

  • columnWidths: the resolved width of every column, refreshed on each geometry change, a resize drag included. Anything the engine does not position itself, a header group for one, is sized from these.
  • columnZones: { left, flow, right }, the column keys of each zone in display order. Where the pinned zone starts and where the flow ends is a fact about the layout, and a caller drawing the seam between them, or filling its own strips, needs it.
  • pinnedLeftWidth / pinnedRightWidth: the width of each pinned zone. The engine keeps its own strips sized, but a header built as a second set of strips has to be told.
  • contentWidth / contentHeight: sizes for the scrollbar spacers, taken from the engine because with variable rows the height is not a multiplication.
  • viewportHeight: the height of the scrolling area as the engine last measured it.
  • rowsPerViewport: how many whole rows fit that area. A table that asks for its data by the screenful gets its page size from here. With row heights that vary it is counted from the base height, because which rows fit depends on which ones they are.
  • grid: the instance itself, for everything the adapter does not wrap.

Pass virtual: () => false to turn the window off: visibleRows then gives every row there is, and the engine positions them all. It places any node registered with it and needs no window of its own to do that. Short tables and tables that print are the reason to.

Resizing from the header

<div
  class="resize-grip"
  @pointerdown="startColumnResize(col.key, $event)"
/>

<div class="header-cell" @click="onHeaderClick(col.key)">{{ col.key }}</div>
function onHeaderClick(key: string) {
  // Releasing the pointer over the header also delivers a click there, and a
  // header that sorts on a click must not sort because a column was widened.
  if (wasRecentResize()) return

  sort.toggle(key)
}

startColumnResize(key, event) starts the drag and remembers when it ended; wasRecentResize() is true for a short while after the release. isActivationKey(event) is Enter or space, for a header cell that has to sort from the keyboard as well.

Resize grips as a layer

Inside a header cell a grip is cut off by its own tier: under a group it covers only the lower tier, and one boundary reads as two stumps. The ResizeHandles component draws the grips of the flow columns as a single layer spanning the whole header, clipped to the flow zone, and follows the scroll from the same event the engine moves the columns from.

<ResizeHandles
  :scroller="scroller"
  :handles="header.handles.map(({ key, edge, depth }) => ({ key, left: edge - 6, top: depth * 40 }))"
  :height="header.depth * 40"
  :pinned-left="pinnedLeftWidth"
  :pinned-right="pinnedRightWidth"
  :start-resize="startColumnResize"
  handle-class="my-grip"
/>

The clipping is the point: a column scrolled under a pinned one is still there, and its grip left on top would take the press meant for the pinned column's own grip. Pinned columns are therefore left out of the list; their grips live inside their header cells, where the engine already carries them. width (6 by default) has to agree with the offset the caller subtracted from the edge. Structure and aria are the component's; how a grip looks is the caller's, through handleClass.

Variable row heights

A row may carry more than its cells: an expanded panel, a nested table, a comment thread. How tall that is cannot be declared, since the panel may grow on its own after it opens, so it is measured instead. Hand the extra element over and the adapter watches it and keeps the row as tall as it needs, base row height plus the panel:

<div v-for="row in visibleRows" :key="row.poolId" :ref="(el) => registerRow(el, row.index)">
  <div v-for="col in columns" :key="col.key" :ref="(el) => registerCell(el, row.index, col.key)">
    {{ row.data[col.key] }}
  </div>

  <div v-if="expanded.has(row.id)" :ref="(el) => observeRowExtra(el, row.index)">
    <RowDetails :row="row.data" />
  </div>
</div>

Passing null, which is what the framework does when the panel goes away, drops the measurement and the row returns to its declared height. A recycled node arriving under another index releases the row it measured before.

Under the hood this is grid.setRowHeight(index, height) and grid.clearRowHeight(index), both available without Vue: the override replaces the source height of one row, everything below shifts, and contentHeight and the visible range are recomputed within the same task.

useRowExpand keeps that bookkeeping for you. Expansion belongs to the row key and not to its place, since after a sort another row stands at that index; the measured heights are dropped from the indexes no expanded row stands at any more, before the rows are redrawn.

const expand = useRowExpand({ rowIds: () => rowIds.value, grid, observeRowExtra })
<span role="button" :aria-expanded="expand.isOpen(row.index)" @click="expand.toggle(row.index)" />

<div v-if="expand.isOpen(row.index)" :ref="(el) => expand.observe(el, row.index)">
  <RowDetails :row="row.data" />
</div>

openIndexes is the whole set, for anything that would rather read it at once.

Custom scrollbars

A native scroller draws both bars inside itself, spanning its full edges; they cannot be moved, and with pinned columns or a sticky header they end up running under content. The Scrollbars component (Vue adapter) draws the two bars as elements of their own, wherever the host lays them out, while scrolling itself stays native. Thumbs are positioned from the same scroll event the engine moves rows from, so bars and content land in their new place within one frame.

<div class="table" style="position: relative">
  <div ref="scroller" style="overflow: auto; scrollbar-width: none">...</div>
  <Scrollbars :scroller="scroller" :inset="{ top: 40 }" />
</div>

The host hides the scroller's native bars and positions the custom ones via inset (distances from the positioned ancestor's edges). thickness sets the bar width, minThumb the shortest a thumb may get, and colors come from CSS variables --dom-grid-track and --dom-grid-thumb, both with neutral defaults. Bars hide themselves when their axis does not overflow. Dragging the thumb, pressing the track and wheeling over a bar all work as the browser's own bars do.

Row reordering

createGrid({
  ...,
  onRowDrop: (from, to) => moveRow(from, to),   // reordering the data is yours
})

grid.startRowDrag(index, pointerEvent)          // from a drag handle

While a row is dragged the engine moves it under the pointer, opens a gap at the drop position and scrolls when the pointer nears an edge. The dragged row carries a data-dragging attribute so it can be styled. Drop indices are reported the way an array behaves after a splice, and reorderRows(rows, from, to) does exactly that, giving the same array back when nothing moved so the caller can tell there is nothing to report.

Input

Wheel and touch scrolling are handled on the root out of the box, including shift-wheel for horizontal movement; the event is only swallowed when the table actually moved, so a table scrolled to its end still lets the page scroll. Pass wheel: false to opt out. In scrollMode: 'native' the browser does this itself and the engine keeps out of it.

Columns are laid out in the width of viewport (the Vue adapter defaults it to the body, layoutFrom: 'root' switches back). Measure the element the content actually gets: overlay scrollbars make the root wider than the space columns can use.

Markup contract

The engine positions nodes but does not style them. Your CSS has to provide:

  • root with position: relative and overflow: hidden, it defines the available width;
  • header cells and body cells with position: absolute, the engine sets transform and width;
  • rows with position: absolute, the engine sets transform and height;
  • scrollbars as separate scrollable elements with an inner spacer sized from contentWidth / contentHeight.

Pinned columns

By default pinned columns cancel out the container shift in the scroll handler. With a native scroller the browser paints the shift itself, so a fast horizontal fling can show them a frame behind the flow.

Give them strips of their own and that disappears: the engine then places pinned cells at their offset inside the strip and never touches them again, and holding the strip in place is the page's job. A strip can be placed either way:

  • inside the scroller, held by position: sticky, so the browser keeps it in step with the scroll it is painting, on both axes;
  • outside the scroller, an absolutely positioned layer over the table, so nothing moves it horizontally at all, and the engine offsets its rows by the vertical scroll, the same way it does in overlay mode. Which one a strip is, the engine works out on its own from where the element sits.
createGrid({ root, body, pinnedLeftLayer, pinnedRightLayer, /* ... */ })

Each strip holds its own copy of every visible row, so a row index has up to three elements, one per strip, and each is registered for what it is:

grid.registerRow(element, index)           // the flow
grid.registerRow(element, index, 'left')   // the left strip
grid.registerRow(element, index, 'right')  // the right strip

Cells need no such hint: a column is pinned or it is not, and registerCell already knows which. The engine keeps every strip sized to the columns it holds, including through a resize drag. In Vue the strips are pinnedLeftRef and pinnedRightRef, and the adapter tells the engine when they come and go; without Vue that is grid.setPinnedLayers(left, right). It matters, because a strip can disappear with the last pinned column hidden, and the scrolling area is a different thing then.

Strips also change what the scrolling area is. Without them it spans the whole table: the flow reserves room for the pinned zones and slides underneath them, so contentWidth counts all three. With them the area belongs to the flow alone, contentWidth is flowWidth, columns start at the very beginning of it, and a scrollbar the browser draws for that area stays between the pinned columns instead of running under them. Place the scrolling element between the strips and give every pinned column an explicit width: there is no leftover space for a pinned auto column to claim a share of, so it falls back to minColumnWidth.

A header placed inside the scroller is left alone as well: the browser carries it with the rest of the content, and nudging it would shift it twice over.

Working with heavy cells

Virtualization removes rows from the DOM, which destroys whatever lived inside them. When cells are expensive (a select with thousands of options, an editor), pair this with dom-attic: dom-grid owns where a cell sits, dom-attic keeps what is inside it alive.

Development

npm run dev        # playground
npm test           # unit tests, run in a real browser
npm run test:e2e   # scroll, pinning and resize scenarios
npm run build

License

MIT