Virtualized 100k-Row Table — Free JS Snippet

Virtualized 100,000-Row Table in Vanilla JavaScript · Tables · Plain HTML, CSS & JS · Live preview

CategoryTables

What's included

Features

Renders only visible rows plus a small buffer — roughly 20 of 100,000
Fixed row height makes the visible range simple arithmetic
Scrollbar height from a sizer element and a translateY-positioned row window
requestAnimationFrame coalescing of scroll events
Sort and filter via an index view array — rows are never copied
Sort indicators with aria-sort and true aria-rowcount / aria-rowindex
Debounced filter and a jump-to-row control that centres and flashes the row
No library, no build step

About this UI Snippet

Virtualized 100,000-Row Table — HTML, CSS & JavaScript

Screenshot of the Virtualized 100,000-Row Table in Vanilla JavaScript snippet rendered live

Put a hundred thousand rows into a normal HTML table and the browser stops being usable. Creating that many elements takes seconds, every one of them costs memory, and layout and scrolling turn into a slideshow. The fix is virtualization, sometimes called windowing: the user can only see about ten rows at once, so render only those and fake the rest. The scrollbar still represents the whole dataset, but the DOM holds a few dozen rows at any moment. The counters above the table make this concrete — 100,000 rows in the data, but only around 20 in the DOM.

The technique is surprisingly small when row height is fixed, which is why this snippet fixes it at 36 pixels. An empty sizer element is given a height of rowCount × 36, which makes the browser draw a scrollbar for the full dataset. On every scroll, the first visible row is scrollTop divided by row height and the last is that plus the viewport height divided by row height — plain arithmetic, no measuring of any element. Only that slice is turned into HTML, with a few buffer rows above and below so fast scrolling never flashes blank space, and the whole rows container is moved into place with a translateY transform. Variable row heights are much harder because you must measure or estimate every row; fixed height is the technique's sweet spot.

Two performance details matter as much as the windowing. Scroll events fire far more often than frames are painted, so the handler only schedules a render with requestAnimationFrame if one is not already pending, coalescing bursts into one update per frame. And the data is never copied for sorting or filtering: a view array holds indexes into the original rows, and is null when nothing is applied so no hundred-thousand-entry array is allocated. Sorting sorts the indexes, not the objects.

The rest is what makes it a usable table. Header buttons sort with aria-sort updated for assistive technology, the filter is debounced by 150 milliseconds, and Jump to row sets scrollTop so the target row lands in the middle and briefly flashes. Because only visible rows exist, browser find-in-page and screen-reader table navigation cannot reach off-screen rows, which is the real cost of virtualization; aria-rowcount and aria-rowindex tell assistive technology the true size and position, and a server-side search is the honest answer when full-text find matters.

Step by step

How to Use

  1. 1
    Scroll fastDrag the scrollbar or use the mouse wheel. The DOM row counter stays near 20 while the table represents 100,000 rows.
  2. 2
    Sort a columnClick a header to sort all 100,000 rows. Click again to reverse. The sort arrow follows the active column.
  3. 3
    FilterType a name or email fragment. The row count drops and the scrollbar shrinks to match the results.
  4. 4
    Jump to a rowEnter a row number and press Go. The table scrolls there, centres the row and flashes it.
  5. 5
    Watch the countersCompare "rows in data", "shown" and "DOM rows" to see how few elements are actually rendered.

Real-world uses

Common Use Cases

📜
Logs and audit trails
Browse very long event lists, rendering only about 20 of 100,000 rows with a small buffer so scrolling stays smooth.
🗂️
Large exports and admin lists
Show every customer or order without pagination, using fixed row height so the visible range is simple arithmetic.
🧰
Developer data browsers
Build data browsers and inspectors that must stay fast with huge datasets, with scroll events coalesced through requestAnimationFrame.
⚖️
Library comparison
Compare with the Tabulator sortable filterable grid for a library-based approach to very large tables with built-in features.
🎓
Windowing learning
Study a dependency-free explanation of how a sizer element creates the scrollbar height while a translated row window shows only the visible slice.

Got questions?

Frequently Asked Questions

Rendering only the items currently visible, plus a small buffer, while making the scroll area the size of the full list. The DOM stays small no matter how much data there is.

With a constant height, the first visible row is just scrollTop / rowHeight. Variable heights require measuring or estimating every row.

Scroll events can fire many times per frame. Scheduling one render per animation frame avoids wasted work and jank.

It sorts an array of indexes rather than copying row objects, and only the visible slice is turned into DOM.

Off-screen rows do not exist, so find-in-page and screen reader navigation cannot reach them. aria-rowcount and aria-rowindex describe the full size, but server-side search is better for finding text.

react-window and react-virtual for React, TanStack Virtual for several frameworks, and AG Grid or Tabulator's virtual DOM for full grids.

Yes. Use the JSX, Vue, Angular or Tailwind export buttons on this page. It has no library dependency, so the logic ports directly: keep the scroll position in state, compute the first and last visible index with the same arithmetic, and render only that slice. For production React or Vue apps, TanStack Virtual or react-window give you the same technique with variable row heights.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Ask an AI assistant like Claude to support variable row heights with measured caching, sticky group headers, or keyboard navigation with a roving row focus.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a virtualized table in vanilla JavaScript that stays fast with 100,000 rows.

Requirements:
- Generate 100,000 row objects. Use a fixed row height of 36px; make a sizer element with height rowCount * 36 inside a scroll container, and render only the visible rows plus a buffer of 6 into an absolutely positioned container moved with translateY.
- Coalesce scroll events with requestAnimationFrame.
- Sort and filter using an array of indexes into the data (null when nothing applies) instead of copying rows; sortable headers with aria-sort, and a debounced text filter.
- Add a Jump to row control that scrolls the row to the centre and flashes it.
- Show live counters for total rows, rows shown and DOM rows, and set aria-rowcount and aria-rowindex.

Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.

Source Code

<div class="vt-wrap">
  <div class="vt-bar">
    <input id="vtSearch" type="search" placeholder="Filter by name or email..." aria-label="Filter rows">
    <label class="vt-jump">Jump to row <input id="vtJump" type="number" min="1" value="50000"></label>
    <button type="button" id="vtGo">Go</button>
  </div>
  <div class="vt-stats" aria-live="polite">
    <span><b id="vtTotal">100,000</b> rows in data</span>
    <span><b id="vtShown">0</b> shown</span>
    <span class="vt-hot"><b id="vtDom">0</b> DOM rows</span>
  </div>
  <div class="vt-table" role="table" aria-label="Virtualized data table" aria-rowcount="100000">
    <div class="vt-head" role="row" id="vtHead"></div>
    <div class="vt-scroll" id="vtScroll" tabindex="0">
      <div class="vt-sizer" id="vtSizer"><div class="vt-rows" id="vtRows"></div></div>
    </div>
  </div>
</div>