Virtual Scroll HTML CSS JS — 10000 Item List

Virtual Scroll List · Layouts · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Fixed ITEM_HEIGHT: uniform row height makes index-to-offset math trivial
Full-height spacer: total height of items × ITEM_HEIGHT gives a correct scrollbar
Visible window math: startIndex from scrollTop, endIndex from viewport height plus buffer
Render buffer: extra rows above and below prevent blank flashes on fast scroll
Absolute positioning: each row placed at index × ITEM_HEIGHT inside the spacer
Node recycling: a small pool of rows is rewritten so DOM count stays flat
rAF throttling: scroll bursts coalesce into one render per animation frame
Instant search: a recomputed filtered array resizes the spacer and window
Sort toggle: reorders plain data and repaints only the visible rows
Scales to millions: cost depends on visible rows, not total dataset size

About this UI Snippet

How to Build a Virtual Scroll List for 10,000 Items in JavaScript

Screenshot of the Virtual Scroll List snippet rendered live

Rendering a list of ten thousand rows the naive way creates ten thousand DOM nodes, which janks scrolling, balloons memory and slows every layout. Virtual scrolling solves this by rendering only the handful of rows actually visible in the viewport while making the scrollbar behave as if the whole list were present. This component demonstrates the technique in plain JavaScript — no framework, no library — and adds live search and sort on top. Here is exactly how it works.

The core idea: a tall spacer plus a few real rows

A virtual list has three structural pieces. An outer viewport element has a fixed height and overflow-y: auto, giving it a scrollbar. Inside it sits a spacer whose height equals the total content height — items.length * ITEM_HEIGHT — so the scrollbar's range and thumb size are correct as if every row existed. Layered over the spacer is a small pool of actual row elements that get repositioned and refilled as you scroll. The user scrolls a giant invisible column but only ever sees a dozen or so real DOM nodes.

The single most important constant is ITEM_HEIGHT, the fixed pixel height of every row. A uniform height is what makes the math trivial: any row's vertical position is just its index times this height, and any scroll offset maps directly back to an index.

Computing the visible window

On each scroll, the handler reads viewport.scrollTop and derives which rows to render:

startIndex = Math.floor(scrollTop / ITEM_HEIGHT) visibleCount = Math.ceil(viewport.clientHeight / ITEM_HEIGHT) endIndex = startIndex + visibleCount + BUFFER

startIndex is simply how many full rows have scrolled past the top. visibleCount is how many rows fit in the viewport. BUFFER is a few extra rows rendered above and below the visible range so fast scrolling does not flash blank gaps before the next render catches up. The render loop then iterates only from startIndex to endIndex, a constant-size slice regardless of whether the dataset has a thousand or a million entries.

Positioning rows absolutely

Each rendered row is given position: absolute with top = index * ITEM_HEIGHT inside the spacer. This places every row at its true position in the full list even though only a window of them exists in the DOM. As you scroll, the same pool of row elements is recycled — their content and top values are rewritten to represent different indices — so the node count stays flat. The visual result is indistinguishable from a fully rendered list: smooth scrolling, a correctly sized scrollbar and rows appearing exactly where they should.

Throttling the scroll handler

Scroll events fire rapidly, often many times per frame. To avoid doing redundant work, the render is throttled with requestAnimationFrame: the scroll handler sets a pending flag and schedules a single render on the next animation frame, coalescing a burst of scroll events into one DOM update per frame. This keeps the main thread free and the list buttery even during flings.

Search filtering

Search recomputes a filtered array from the source data based on the query, then resets the virtual list to operate over that filtered array. Crucially, the spacer height is recalculated as filtered.length * ITEM_HEIGHT so the scrollbar shrinks to match the result count, and the visible window is re-derived from the new scrollTop. Because the index math only depends on the array length and item height, searching ten thousand items and re-rendering is instant — only the visible slice is ever touched in the DOM.

Sorting

A sort toggle reorders the underlying (or filtered) array by a field such as name. After sorting, the spacer height is unchanged but the row contents at each index differ, so a single re-render repaints the visible window in the new order. Sorting a huge list stays cheap for the same reason: the sort runs on plain data, and only the visible rows are rebuilt.

Generating the dataset

To exercise the technique, the demo generates ten thousand records programmatically, each with a name, role, status and a colored avatar initial. This synthetic data shows that the approach is bound by the number of *visible* rows, not the total. A counter typically displays how many DOM nodes are actually present — usually a dozen or two — versus the ten thousand a naive list would create, making the performance win concrete.

Why it matters

Virtual scrolling is the standard technique behind every high-performance list and table on the web — chat histories, data grids, file explorers, infinite feeds. The essentials are always the same: a fixed item height, a spacer sized to the full content, index math mapping scroll offset to a visible window, a small render buffer, absolute positioning by index, and rAF-throttled updates. This component distills all of that into a self-contained, dependency-free example you can adapt to any large dataset.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Instead of tracing the index math by hand, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why render() recycles existing row elements (growing or shrinking the pool to match "needed") rather than clearing and rebuilding listItems.innerHTML on every scroll event, and how that choice relates to the rAF-throttled scheduleRender pattern. It's a good optimization target too — ask what would need to change if rows had variable, non-uniform heights instead of the fixed ITEM_HEIGHT this approach depends on. For extending it, have it add keyboard navigation (arrow keys move a focused row and scroll it into view), infinite loading that appends new records as the user nears the bottom, or column virtualization for a very wide table. Treat the code less like a finished artifact and more like a starting point for a conversation.

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 virtual scrolling list capable of smoothly displaying 10,000 rows, in plain HTML, CSS, and vanilla JavaScript with no libraries.

Requirements:
- A fixed-height scrollable viewport container containing two children: an empty "spacer" element whose height is set to totalItemCount times a fixed per-row pixel height (so the scrollbar's size and range behave as if every row existed), and a row container absolutely positioned to overlay the spacer.
- On every scroll event (throttled to at most once per animation frame via requestAnimationFrame, not fired synchronously on every scroll event), compute a start index from Math.floor(scrollTop / rowHeight), an end index from the viewport's visible row count plus a small buffer of extra rows above and below, and only ever touch DOM nodes for that index range.
- Maintain a pool of reusable row DOM elements: grow the pool by appending new row elements when more are needed than currently exist, and shrink it by removing trailing elements when fewer are needed, rather than destroying and recreating every row on each render.
- Position each visible row absolutely at top = its real data index times the fixed row height, so scrolling reveals rows at their true position even though only a small window of them exist in the DOM at any moment.
- Add a text search input that filters the full dataset down to a matching subset, recalculates the spacer height for the new (possibly much smaller) filtered length, resets scroll to the top, and re-renders using the exact same windowing logic against the filtered array.
- Add a sort toggle that reorders the current (possibly filtered) array and re-renders the visible window, without changing the spacer height.
- Generate at least 10,000 synthetic data records to demonstrate that only a small, constant number of DOM nodes exist at any time regardless of total dataset size.

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.

Step by step

How to Use

  1. 1
    Scroll the listScroll through ten thousand items as smoothly as if there were only a few rows.
  2. 2
    Check the DOM countWatch the live node counter show only the dozen-or-so rows actually in the DOM.
  3. 3
    Search instantlyType in the search box to filter the dataset and resize the scrollbar to the results.
  4. 4
    Toggle sortClick sort to reorder the underlying data and repaint the visible window.
  5. 5
    Compare to naiveNote how rendering only visible rows avoids the jank of ten thousand DOM nodes.
  6. 6
    Adapt the dataSwap in your own records and adjust ITEM_HEIGHT to fit your row design.

Real-world uses

Common Use Cases

Large data grids
Render huge tables and directories without freezing the browser, pairing with a radar chart for summaries.
Infinite feeds
Power chat histories, logs or social feeds where thousands of rows must stay scrollable, pairing data with a radar chart for summaries.
File and contact lists
Build file explorers or address books that handle tens of thousands of entries smoothly.
Admin dashboards
Display long result sets with search and sort without per-row rendering cost, alongside pattern lock for access control.
Teaching performance
Demonstrate DOM cost and the windowing technique behind every fast list component, complemented by physics balls for animation rendering lessons.
Search-as-you-type UIs
Filter large datasets live while keeping rendering bound to the visible window.

Got questions?

Frequently Asked Questions

A uniform height makes the math exact: any row position is its index times the height, and any scrollTop maps straight back to a start index. Variable heights require measuring or estimating rows, which is more complex though achievable.

The spacer is an element whose height equals the full list height. It gives the scrollbar the correct range and thumb size so scrolling feels like the whole list is present, even though only the visible rows actually exist in the DOM.

During fast scrolling the next render may not run before new rows enter view, causing a blank flash. Rendering a few extra rows above and below the visible window hides that gap and keeps scrolling seamless.

Search rebuilds a filtered array and resizes the spacer, while sort reorders plain data. Both operate on the underlying array, not the DOM, and only the small visible window is ever re-rendered, so cost stays constant.

Scroll events fire many times per frame. Coalescing them into a single render per animation frame avoids redundant DOM work, keeps the main thread responsive and prevents stutter during fast scrolling.

Yes. Click JSX for a React component, Vue for a Vue 3 SFC, Angular for a standalone component, or Tailwind for a utility-class version. In React, derive the visible slice from scrollTop state in an onScroll handler and render only those rows — or compare with react-window, which implements the same windowing technique.