Render a hundred thousand rows by rendering about twenty of them.
A browser does not care how many rows are in your array. It cares how many are in the document. Put a hundred thousand rows in and the page takes seconds to lay out, eats memory, and janks on every scroll.
But a screen only ever shows a dozen or so. So keep the rows in the array, and put only the visible slice in the document. Two numbers do the work: which row is at the top of the viewport, and which is at the bottom. Everything outside that range becomes empty padding, sized so the scrollbar still tells the truth about how long the list is.
Then one refinement makes it smooth: reuse the row components you already have instead of destroying and building them on every frame.
Auto scroll is off because your system asks for reduced motion. The scroll controls still work.
With one fixed row height, both ends of the window are arithmetic. No measuring, no layout read, no loop over the data.
def window(scroll_top, viewport_h, row_h, total, overscan=2):
first = scroll_top // row_h
last = (scroll_top + viewport_h - 1) // row_h
# overscan: render a little past each edge so a fast scroll never
# exposes an unrendered gap for a frame
start = max(0, first - overscan)
end = min(total - 1, last + overscan)
return start, end, start * row_h, (total - 1 - end) * row_h
# ^ rows to render ^ pad above ^ pad below
When rows differ in height, first is no longer a division. Keep a prefix sum of heights, where offsets[i] is where row i starts, and binary search it for the largest i with offsets[i] <= scroll_top. That turns an O(n) walk into an O(log n) search, and the prefix array is the thing worth caching.
| What | Plain list | Windowed |
|---|---|---|
| Nodes in the document | n | viewport + 2 x overscan |
| Work per scroll frame | 0 | O(1) fixed, O(log n) variable |
| First paint | O(n) layout | O(1) layout |
| Memory held by rows | O(n) | O(pool) |
The trade is that the browser no longer knows what is in the list. Find on page, Ctrl+F, print, and anchor links all only see the rendered window, and that is a product decision as much as a technical one.
scroll_top + viewport_h, and a row straddling the bottom edge is still visible. Using // on the bottom edge without the - 1, or forgetting to include the partial row at all, leaves a one-row gap that only appears at certain scroll positions.A log viewer holds 100,000 lines at 26 pixels each: a document 2.6 million pixels tall. The viewport is 208 pixels, which is exactly eight rows, and the overscan is two rows at each end.
Scroll to pixel 500,000. That is not a row boundary. Row 19,230 begins at 499,980, so its last six pixels are still on screen and the window straddles a partial row at each end. first = 500000 // 26 = 19230 and last = (500000 + 207) // 26 = 19238, so nine rows are touched rather than eight. Add the overscan and the render range is 19,228 through 19,240: thirteen rows in the document. Pad 19,228 x 26 pixels above and the remaining rows below, and the scrollbar is still exactly where it should be.
The eight-versus-nine detail is the whole reason the bottom edge uses + viewport_h - 1. A viewport only shows a whole number of rows when it happens to be scrolled to a boundary, and the rest of the time it shows one more, partly.
Rows are 26 pixels tall, the viewport is 208 pixels, and the user scrolls to exactly pixel 26. Which rows are visible, before overscan?