# Data Grid

The table and gallery engine behind 42 screens of products, assets and operations, built to stay fast at a hundred thousand rows.

- Author: [Max Vyshniakov](https://maxvyshniakov.com/about/)
- Role: Design Engineer
- Period: 4 March – 20 October 2026
- Status: Version 0.22.2, in continued development
- Models: GPT-5.5 (7.1M output tokens, 50%), GPT-5.4 (3.5M output tokens, 25%), GPT-5.6 Sol (1.9M output tokens, 13%), Claude Opus 5.5 (0.8M output tokens, 6%), Claude Fable 5 (0.7M output tokens, 5%), Other models (0.3M output tokens, 2%), Claude Opus 4.6 (not logged), Claude Opus 4.8 (not logged). Only sessions kept on this machine count: Codex from 10 April, Claude Code from 14 July. The March work in Studio isn't counted.
- Stack: React, TypeScript, TanStack Table, TanStack Virtual, TanStack Store, Canvas API, Sid-Kit, Storybook, Inline Measure, Text Truncate, scroll-driven animations, Vitest, Testing Library, Playwright, Bun, GitHub Packages, Cloudflare Workers
- Category: Data interfaces & performance
- Topics: Interaction design, Performance, Data interfaces
- Web page: https://maxvyshniakov.com/work/data-grid/
- Links: [Browse the Storybook](https://data-grid.maxvyshniakov.com/), [Live demo: Data Grid: the product screen](https://data-grid.maxvyshniakov.com/releases/fe5d0a715935cdfc72712965a4f9b9c1909f6e30/?path=/story/data-grid-showcase--default)

One night in June an agent reported that switching Studio's products from cards to a table now took about 100 ms. On my screen it still froze for a second. I sent the number back, and over the next day had the agent rule out one cause at a time. Thumbnails, cell content and routing barely moved it; measuring column widths was half the freeze. So fitted widths are now cached and recomputed only when something they depend on changes. Data Grid is the table and gallery engine behind 42 screens in Sirv AI Studio and Sparta. I decided what fast means, by what I see and feel on a real screen, and turned each regression into a rule and a test.

![The Data Grid in the dark theme. A Products toolbar with 10,000 items, Quick search, S, M and L density, a Table and Gallery switch and Customize view sits above rows of furniture with product photos, SKUs, status badges, prices, stock, readiness bars, owners, dates and tags.](https://maxvyshniakov.com/media/5b2d15bef99c92e5ea9bcb3b45ae9e878f4b83f1ff7bd7899eb1cec768b64561.webp)

A products screen over Byronworks, a fictional catalog.

## At a glance

* 42: screens in Sirv AI Studio and Sparta
* 2 s → 26 ms: to fit a column of 85 cells, once the reads were batched
* 67: versions, from 0.0.1 in May to 0.22.2 in October
* 600+: unit, interaction and render-count tests

- Switching products between cards and a table froze for about a second, and half of it was measuring column widths again. → [Cache fitted widths and remeasure only on an explicit signal](https://maxvyshniakov.com/work/data-grid/#a-reported-100-ms-was-a-one-second-freeze)
- The library and products each had their own selection and keyboard handling, so the two screens behaved differently under the same hand. → [One grid instance owns selection and find for both views](https://maxvyshniakov.com/work/data-grid/#table-and-cards-are-one-grid)
- Fitting a column and truncating text answered the same question in two different ways, and a real cell holds far more than a string. → [Measure what a cell really holds, in a package of its own](https://maxvyshniakov.com/work/data-grid/#a-columns-width-comes-from-what-its-cells-hold)
- A Shift-click on products blocked the main thread for 1.3 s, mostly React re-rendering rows that hadn't changed. → [Visual feedback goes around React; data changes keep a React path](https://maxvyshniakov.com/work/data-grid/#selection-paints-rows-without-re-rendering-them)

## A reported 100 ms was a one-second freeze

An agent's number is a claim, and the screen in front of me is the test. On the night of 15 June I asked Codex why switching Studio's products between a gallery and a table still stalled: a click, a pause, then everything updated at once. It profiled the switch, moved the address-bar update until after the first paint, and reported the first frame down from about 605 ms to about 106 ms.

That isn't what I saw.

> I don't know what first frame you're counting. I don't see any 100 ms when I switch tabs. I see a freeze of about a second.
>
> — Me to Codex, 16 June at 02:50

It measured again, this time the path I'd actually clicked, from the open view sheet. The tab lit up within 50–190 ms, but the real switch took 870–1,050 ms, with one long task of 640–740 ms. Its 100 ms had timed a different button, up to the first frame. It said so plainly: it had used the wrong proxy, and it wasn't going to pretend the problem was fixed.

The next evening I asked a different question. I'd heard about WebAssembly and workers; could they recalculate the table's geometry off the main thread? Before building anything, I asked for proof in a real browser that it would matter, and then for every other suspect to be tested the same way, by switching it off and measuring again.

The agent ran each experiment on a production build. A baseline gallery-to-table switch took about 2.2 s. Blocking every image request changed it by 1%. Replacing rich cells with plain text, by 3%. Switching off column fitting and refitting halved it, to about 1.1 s. The other half was the cost of mounting real rows and cells. None of it could move to a worker, because DOM measurement and layout happen on the main thread, so WebAssembly wouldn't help. The question became how often the grid needs to measure at all.

Problem: Switching products between cards and a table froze for about a second, and half of it was measuring column widths again. **Solution: Cache fitted widths, and remeasure only when someone resizes, fits or resets a column, or the view, the viewport or the first rows change.** Cost: A cache can go stale. Its key has to describe the view, the viewport and a cheap signature of the first rows' content, and anything outside that key keeps an old width. Result: Since 17 June a view that comes back unchanged takes its widths from the cache without touching the DOM, and the debug inspector shows whether geometry was recalculated.

I also set the bar for the next time. A table that is visible but ignores clicks isn't ready, so the agent measured when a click first opened a dialog, not when rows appeared. And I asked it to learn to detect empty frames and skeletons itself, so that checks run as tests rather than as my screen recordings.

## Table and cards are one grid

The same grid instance renders as a table or as a gallery of cards, because the work a user has done belongs to the data, not to the view. Selection and the find query live in the instance, so switching to cards and back loses nothing. The gallery counts its columns from its own width; when the panel resizes, cards glide to their new places, and sections collapse with the cards below sliding up.

![Products as a gallery of cards with photos of chairs, stools, tables, a sofa and a lounge chair, each with a status badge. Find is open on oak, 1 of 35, with the current match's card highlighted, and a floating bar reads 3 selected, Duplicate and Archive.](https://maxvyshniakov.com/media/892cc79afc129726fba922936959a099791c3585196ab8278295aeb73bb69c66.webp)

The same grid as cards, with a selection and find.

[Open the live demo in Storybook: Data Grid: table and cards](https://data-grid.maxvyshniakov.com/releases/fe5d0a715935cdfc72712965a4f9b9c1909f6e30/?path=/story/data-grid-gallery--table-and-cards)

It didn't start that way. Studio's asset library began as a 1,200-line gallery component that held the list, selection and keyboard handling together. Igor Varyvoda, who created Studio, had built its marquee selection and gallery keyboard navigation in February. On 4 March I replaced that component with grid primitives and 44 tests, and Igor kept building on them. From late March the grid was mine. By then the products table had grown its own copy of selection and keyboard handling, and the two screens behaved differently under the same hand.

On 6 April I moved both onto one model: eight generic hooks shared by the library and products, with about 500 lines of product-only keyboard code deleted.

Problem: The library and products each had their own selection and keyboard handling, so the two screens behaved differently under the same hand. **Solution: One model of selection, cursor and keyboard for every grid screen, owned by the grid instance rather than by each view.** Cost: Products gave up its own behaviour: a single click now selects and a double click opens, as in the library. Result: Both screens run on eight shared hooks, about 500 lines of product-only keyboard code are gone, and every later fix to selection reaches every screen.

A host screen doesn't wire this by hand. One hook, the scene layer, assembles columns, selection, menus, find and the table's props, so a new operator screen in Sparta is mostly a list of column definitions.

## A column's width comes from what its cells hold

Fitting a column to its content means measuring the cell, not the string. A real cell can hold a thumbnail, a badge, a label, a menu trigger and several lines of text.

I met the problem as a bug on 8 April. Fitting a column cloned each cell, measured it and removed it, forcing a synchronous layout on every read: 85 cells took about two seconds. Batching the clones into one off-screen container and reading them in a single reflow brought it to about 26 ms.

Six weeks later the same measuring turned up in a second place. Text Truncate was deciding where to cut text and when to show a tooltip, and fitting a column was answering almost the same question in its own way.

> So maybe we should start a little package, text-measure, and use it in both Text Truncate and fit to content?
>
> — Me to Codex, 18 May at 19:36

That evening the measurer became a package, Inline Measure. It measures on a canvas, in a hidden DOM node, or on a canvas calibrated against the DOM for tabular digits. Font loading is part of its cache key, so a width measured with a fallback font is never reused.

Problem: Fitting a column and truncating text answered the same question in two different ways, and a real cell holds far more than a string. **Solution: One measurement layer for fitting and truncation, where composite cells describe their parts: text segments, gaps, fixed controls and chrome.** Cost: Every composite cell has to describe its structure, and measuring stays DOM work on the main thread. In June it was half of a two-second freeze. Result: Fixed parts are calibrated once, a hidden control leaves no imaginary gap, and column fitting and Text Truncate measure text with the same code.

Two- and three-line rows ask a different question: the narrowest whole-pixel width at which the text fits in that many lines. The solver searches over Unicode line-break opportunities, respects non-breaking spaces and soft hyphens, and can confirm the answer against real wrapping. Each column then chooses an ellipsis, a fade, wrapping or nothing, and the tooltip appears only when something was actually cut. Text Truncate's middle mode never splits a grapheme, so an ID or a file path keeps both ends.

The rest of a column's life follows from that. Edges drag to resize, headers drag to reorder, and columns pin left or right while the rest slide underneath. A table of 24 or more columns virtualizes them horizontally too.

![A wide product table scrolled sideways. The Product column stays pinned on the left and Status on the right, while Collection, Price, Stock, Readiness, Rating, Owner, Warehouse and Material pass between them.](https://maxvyshniakov.com/media/f49ab83e1edeee09263619cb7874a7b92844aff8ecb5bff465d50130d911d09a.webp)

Product pinned left, Status pinned right.

[Open the live demo in Storybook: Data Grid: pinned columns](https://data-grid.maxvyshniakov.com/releases/fe5d0a715935cdfc72712965a4f9b9c1909f6e30/?path=/story/data-grid-columns--pinning)

## Measure on the build people use

A slow grid on the dev server proves nothing until it's slow on a production build. I learned that on 27 April, chasing scroll jank in Studio's products. Trackpad scrolling was smooth; the mouse wheel stuttered, and only when real rows were on screen. The inspector showed both covered the same range, so the difference was in how many rows arrived at once. I suggested the fix: a small batch rendered smoothly, so cut a big batch into a series of trackpad-sized ones. It helped, but not enough.

Then I opened the deployed build, and it didn't stutter at all. I stopped the fixes and asked for a copy of production running on my machine, to rule out the build itself, "so we're not tilting at windmills". Meanwhile the dev build kept stuttering even with the grid's own inspector switched off.

> Prod doesn't lag. How is that even possible?
>
> — Me to Codex, 27 April at 12:41

Seven minutes later the local production build was as smooth as the deployed one. Part of what we'd been fixing was the dev build itself. Since then the measurements that matter run on a local production preview, the inspector can switch off React's StrictMode, Sentry tracing and replay one by one, and the grid's performance notes carry a rule: don't attribute grid performance by reading code; trace the metric, switch off the suspect path, then profile. It is the method that found the geometry in June.

What the grid does now is in the Storybook. It scrolls a hundred thousand fictional products, with an overscan that grows with scroll speed, and during a fast fling rows that haven't mounted yet paint as placeholders instead of empty space. The header is synchronized with the body on the compositor, through scroll timelines where the browser supports them. While its container is resized, the grid freezes its column window and widths and commits once the resize has been idle for 180 ms.

![The table in the middle of a 100,000-product catalog: rows BW-CHR-51000 to BW-LGT-51018 with statuses including Archived, Draft, In review and Paused, and a footer that reads 100,000 products and Rows 50,001–50,019.](https://maxvyshniakov.com/media/3fdc1bc4fc8302bf0e57c69f2cf32080ba7d1188ad13d103d3a24731b9c2a295.webp)

Halfway through a hundred thousand products.

[Open the live demo in Storybook: Data Grid: a hundred thousand rows](https://data-grid.maxvyshniakov.com/releases/fe5d0a715935cdfc72712965a4f9b9c1909f6e30/?path=/story/data-grid-scrolling--hundred-thousand-rows)

## Selection paints rows without re-rendering them

Selection has to answer as the hand moves, so it doesn't wait for React. It works the way people expect from a file manager: click, Shift-click with a live preview of the range, ⌘-click, and a marquee that scrolls at the edges, in the table and the gallery. The keyboard has its own cursor; arrows move it without selecting, and in the gallery they follow the visual columns.

![Three selected products in the table, Clear cantilever chair, Retro diner chair and Factory brass bar stool, all live, with a floating bar below: 3 selected, Select all 1,000, Archive and Delete.](https://maxvyshniakov.com/media/cf38cc01713692465b3041fa2f5f42f96091b51e1284b32092dfa6c84481ce41.webp)

The selection bar offers only what applies.

[Open the live demo in Storybook: Data Grid: bulk actions](https://data-grid.maxvyshniakov.com/releases/fe5d0a715935cdfc72712965a4f9b9c1909f6e30/?path=/story/data-grid-selection--bulk-actions)

In early April it was far from that. Profiling Studio's two grid screens showed a Shift-click on products blocking the main thread for 1,271 ms, and five Shift+Arrow presses in the library timing out after 15 s. Much of that time was React re-rendering rows that hadn't changed.

**Under the hood: Studio, April 2026**

One round of measurements, profiled with Playwright. They show the size of the problem then, not the grid's performance today.

| Studio, April 2026 | Before | After that round |
| --- | --- | --- |
| Products, Shift-click: blocking time | 1,271 ms | 337 ms |
| Products, scroll: longest task | 1,240 ms | 347 ms |
| Library, five Shift+Arrow presses | timed out after 15 s | about 3 s |
| Overflow tooltip components in the grid | about 520 | 0, one delegated listener |

What came out of that round was an architecture with two paths. Hover, the cursor, marquee and range selection and select-all are projected onto rows already on screen through data attributes, `aria-selected` and checkbox state, without re-rendering their content. Data changes keep an explicit React path: when content changes behind stable rows, such as a language switch, the host bumps a content revision instead of generating new row IDs, which would wipe out selection, find and the scroll position.

Problem: A Shift-click on products blocked the main thread for 1.3 s, mostly React re-rendering rows that hadn't changed. **Solution: Cheap visual feedback goes around React onto mounted rows; data changes keep an explicit React path.** Cost: Two state paths to keep in sync, and the guarantee holds only where tests prove it: render-count tests cover specific table paths, not all React work in the grid. Result: In that round a Shift-click on products went from 1,271 ms of blocking to 337 ms. In the covered paths hover, selection, the cursor and live resizing leave mounted rows alone, and tests fail if that changes.

Selection isn't the only mark. The inspected row is the one open in a details panel, and it carries an edge of its own without being selected. A host that flags rows by its own rules, such as low stock, marks them in a cell.

![A product table with two selected rows outlined in blue and a count that reads 2 selected. The Ridge live-edge coffee table row carries a blue edge as the one open in the Details panel on the right, which shows the table, its SKU, status, price, stock, owner and description.](https://maxvyshniakov.com/media/089d3cf1938463afd0ae1a13b36ee2859cd31790881683be2ab97e67ddf0c6b1.webp)

Selected and inspected rows.

[Open the live demo in Storybook: Data Grid: selected and inspected](https://data-grid.maxvyshniakov.com/releases/fe5d0a715935cdfc72712965a4f9b9c1909f6e30/?path=/story/data-grid-selection--selected-and-inspected)

## Find searches rows that haven't loaded

⌘F inside the grid searches the data, not the rows on screen. Igor Varyvoda built the first cross-page find for Studio; I generalized it into the package. The server reports where matches are, and the grid loads them in batches ahead of the cursor. While a match is still loading, the active result waits for it instead of jumping to another row, and background loading pauses so the two don't fight over the scroll position. Ten of Sparta's operators' screens search this way.

Sorting and filtering start from the header. Shift-click adds a second and third sort level, numbered in the header, and an open filter is only a draft until Apply.

Filtering and sorting from the header.

* **A filter from the header**
  ![A Category filter popover open under its header, with a search field and a list of categories, Cancel and Apply. The Status header shows a funnel for an applied filter.](https://maxvyshniakov.com/media/87f47e89fe8eaeebc270f81b306f4d90f2135a52ca5f2709b7bf59e9868833af.webp)
* **Two sort levels, numbered**
  ![A table sorted by Status first and Price second, with small numbered arrows in both headers. The most expensive live sofas are at the top.](https://maxvyshniakov.com/media/183431ecf20a821b4a3511791aa4072418396cc39fd0faaa3b51de6a26f690c0.webp)

The same settings live in one view sheet beside the grid, which edits the table behind it as the user goes: columns, sort levels and grouping, with saved views that can be saved, reset or cancelled.

![The View sheet open on its Sort tab beside a dimmed product table. Category is the primary sort and Price the secondary, each with its direction, above a list of available fields and a Done button.](https://maxvyshniakov.com/media/3a775b8a03a1ca42e2126936c2f8ac7728c6072d8f9e5ac75de294701349c1ac.webp)

The view sheet's Sort tab.

[Open the live demo in Storybook: Data Grid: the view sheet](https://data-grid.maxvyshniakov.com/releases/fe5d0a715935cdfc72712965a4f9b9c1909f6e30/?path=/story/data-grid-view-options--edit-view)

## The reader never loses their place

Every change the grid makes keeps the reader where they were. Rows come in three densities, and switching keeps the first fully visible row exactly in place. A deleted row stops taking clicks at once and the rows below close the gap, but only when the host names the removed rows and the new list is the old one without them, so a refetch or a sort can never look like a deletion.

Loading is drawn inside the same frame and under the same header, so a screen never swaps its table for a spinner. Skeletons take the shape of each column's content, a refetch dims the rows it will replace, and the scrollbar follows the server's full total from the start. A jump far down a long list loads that range rather than every page in between, and the server renders a first frame with the same geometry as the client, so nothing shifts when the data arrives.

## How it was built

The grid was built with coding agents. In March Claude Opus 4.6 wrote most of it in Studio. From April Codex on GPT-5.4 and GPT-5.5 took over most of the work through June: the profiling runs, the experiments and the fixes. After my contract with Sirv ended in June, I kept developing the grid inside Sparta, where its code grew by about 40% in July. In September it moved into its own repository, where Claude Opus 5.5 co-signed 110 of 130 commits.

The standard was mine to set: what fast and correct mean, checked by hand: a freeze I could see, a wheel that stuttered, a table that looked ready and ignored clicks. When an agent's report and the screen disagreed, the screen won, and the agent had to find out why. Igor Varyvoda shaped it too: he built the original selection and the first cross-page find, and suggested upgrading TanStack Table when the grid became a package.

Not every lesson was about speed. On 4 May an agent of mine moved the grid's interface components behind replaceable slots, and the change broke Studio's production build. Igor spent half an hour fixing it. I asked for a post-mortem, and the next day the package gained import guards for its slots. Its interface still goes through those slots, with a ready-made Sid-Kit integration of about 50 components.

**Seven months at a glance**

* 4 Mar (2026-03-04): **Grid primitives replace a 1,200-line gallery.** With 44 tests, in Studio's asset library.
* 6–8 Apr (2026-04-08): **One model, then the first profiling round.** Both screens share eight hooks; fitting 85 cells drops from 2 s to 26 ms.
* 27 Apr (2026-04-27): **Prod doesn't lag.** Measurements move to a local production build.
* May (2026-05-25): **A package of its own.** Inline Measure and Text Truncate split off; 0.0.1 on 25 May.
* 15–17 Jun (2026-06-17): **The one-second freeze.** Ruled out cause by cause; fitted widths are cached.
* Jul–Oct (2026-10-20): **Sparta, its own repository, 0.20.1.** 37 screens in Sparta and a public Storybook on 20 October.

The useful result is one place to fix behaviour. A measurement error, a selection race or a deletion glitch is repaired in the package, and every app gets it with the next update.

The limits are written down as well. The render-count guarantees cover specific table paths, not all React work in the grid. The virtualized grid has a known screen-reader limitation, and its accessibility notes say plainly not to claim full screen-reader grid support.

Explore the full catalog: [Data Grid Storybook](https://data-grid.maxvyshniakov.com/).

The grid is built on Sid-Kit, which has [a case study of its own](https://maxvyshniakov.com/work/sid-kit/). Sparta put both into wholesale purchasing and day-to-day operations. [Read the Sparta case study](https://maxvyshniakov.com/work/sparta/).
