Skip to content

Commit bbfc4bf

Browse files
committed
feat(core): implement built-in scroll-scaling for large lists
1 parent 949180b commit bbfc4bf

4 files changed

Lines changed: 441 additions & 12 deletions

File tree

.changeset/scroll-scaling.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@tanstack/virtual-core': minor
3+
---
4+
5+
feat: Add built-in scroll-scaling to bypass browser scroll height limits
6+
7+
Browsers cap `scrollHeight` at approximately 33.5 million pixels, making items at the end of very large lists unreachable. The virtualizer now automatically detects when the total virtual size exceeds the configurable `maxScrollSize` (default: 33,000,000 px) and applies a transparent scale transform to compress the scroll range.
8+
9+
**New option:**
10+
- `maxScrollSize` — Maximum physical scroll container size in pixels. Set to `Infinity` to disable scaling. Default: `33_000_000`.
11+
12+
**New property:**
13+
- `scale` — The current scale factor (1 when no scaling is active).
14+
15+
When scaling is active:
16+
- `getTotalSize()` returns the capped physical size for use as the container's CSS height/width.
17+
- `getVirtualItems()` returns items with physical coordinates — use `item.start` directly for positioning.
18+
- `scrollToIndex()`, `scrollToOffset()`, and `scrollBy()` work transparently.
19+
- Scroll anchoring (resize adjustments for items above the viewport) works correctly through the scale transform.
20+
21+
When scaling is **not** active (the vast majority of use cases), there is zero overhead — the existing code paths are unchanged.
22+
23+
This feature is implemented entirely in `virtual-core` and works across all framework adapters (React, Vue, Solid, Svelte, Angular, Lit) with no adapter changes required.

docs/api/virtualizer.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,43 @@ Controls when lane assignments are cached in a masonry layout.
245245
- `'estimate'` (default): lane assignments are cached immediately based on `estimateSize`. This keeps items from jumping between lanes, but assignments may be suboptimal when the estimate is inaccurate.
246246
- `'measured'`: lane caching is deferred until items are measured via `measureElement`, so assignments reflect actual measured sizes. After the initial measurement, lanes are cached and remain stable.
247247

248+
### `maxScrollSize`
249+
250+
```tsx
251+
maxScrollSize?: number
252+
```
253+
254+
**Default**: `33_000_000`
255+
256+
Maximum physical scroll container size in pixels. Browsers cap `scrollHeight` at approximately 33.5 million pixels. When the total virtual size of all items exceeds `maxScrollSize`, the virtualizer automatically applies a scale factor to compress the scroll range so that all items remain reachable.
257+
258+
When scaling is active:
259+
- `getTotalSize()` returns the capped physical size (use this for your container's CSS height/width)
260+
- `getVirtualItems()` returns items with physical coordinates (use `item.start` directly for `translateY`/`translateX`)
261+
- `scrollToIndex()` and `scrollToOffset()` work transparently
262+
- The `scale` property reflects the current scale factor
263+
264+
Set to `Infinity` to disable scaling entirely.
265+
266+
```tsx
267+
// Example: 1 million items at 40px each = 40M px (exceeds browser limit)
268+
const virtualizer = useVirtualizer({
269+
count: 1_000_000,
270+
estimateSize: () => 40,
271+
getScrollElement: () => parentRef.current,
272+
// maxScrollSize defaults to 33M — scaling activates automatically
273+
})
274+
275+
// Everything works as normal — no code changes needed:
276+
<div style={{ height: virtualizer.getTotalSize() }}> {/* capped at ~33M */}
277+
{virtualizer.getVirtualItems().map(item => (
278+
<div style={{ transform: `translateY(${item.start}px)` }}> {/* physical */}
279+
...
280+
</div>
281+
))}
282+
</div>
283+
```
284+
248285
### `isScrollingResetDelay`
249286

250287
```tsx
@@ -397,6 +434,8 @@ getTotalSize: () => number
397434
398435
Returns the total size in pixels for the virtualized items. This measurement will incrementally change if you choose to dynamically measure your elements as they are rendered.
399436
437+
When scroll-scaling is active (i.e., the virtual total exceeds `maxScrollSize`), this returns the capped physical size suitable for use as the container's CSS height/width. Use the `scale` property to recover the uncapped virtual total if needed.
438+
400439
### `measure`
401440
402441
```tsx
@@ -511,3 +550,15 @@ scrollOffset: number
511550
```
512551

513552
This option represents the current scroll position along the scrolling axis. It is measured in pixels from the starting point of the scrollable area.
553+
554+
When scroll-scaling is active, this value is in virtual (unscaled) coordinate space, which may be larger than the physical scroll position reported by the browser.
555+
556+
### `scale`
557+
558+
```tsx
559+
scale: number
560+
```
561+
562+
The current scale factor applied by the virtualizer. Returns `1` when the total virtual size is within the `maxScrollSize` limit (no scaling needed). When scaling is active, this value is greater than `1`.
563+
564+
You can use this to recover the real (unscaled) size of an item: `realSize = item.size * virtualizer.scale`.

packages/virtual-core/src/index.ts

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,10 @@ export interface VirtualizerOptions<
334334
isRtl?: boolean
335335
useAnimationFrameWithResizeObserver?: boolean
336336
laneAssignmentMode?: LaneAssignmentMode
337+
/** Maximum physical scroll container size in pixels. When the virtual total
338+
* size exceeds this value, the virtualizer applies a scale factor to compress
339+
* the scroll range. Defaults to 33_000_000. Set to Infinity to disable. */
340+
maxScrollSize?: number
337341
}
338342

339343
type ScrollState = {
@@ -378,6 +382,17 @@ export class Virtualizer<
378382
scrollOffset: number | null = null
379383
scrollDirection: ScrollDirection | null = null
380384
private scrollAdjustments = 0
385+
386+
private getScale = (): number => {
387+
const virtualTotal = this.getTotalVirtualSize()
388+
const max = this.options.maxScrollSize
389+
return virtualTotal > max ? virtualTotal / max : 1
390+
}
391+
392+
/** Current scale factor. Returns 1 when no scaling is active. */
393+
get scale(): number {
394+
return this.getScale()
395+
}
381396
// Sum of size-change deltas above-viewport that were skipped during
382397
// iOS momentum scroll (writing scrollTop mid-momentum cancels it).
383398
// Flushed in a single scrollTo when iOS is fully settled.
@@ -504,6 +519,7 @@ export class Virtualizer<
504519
useScrollendEvent: false,
505520
useAnimationFrameWithResizeObserver: false,
506521
laneAssignmentMode: 'estimate',
522+
maxScrollSize: 33_000_000,
507523
} as unknown as Required<VirtualizerOptions<TScrollElement, TItemElement>>
508524

509525
for (const key in opts) {
@@ -602,6 +618,8 @@ export class Virtualizer<
602618
// self-write — by the time the user has moved 1.5 px, the
603619
// intended value will already have been consumed by a prior
604620
// scroll event and cleared.
621+
// Note: both offset and _intendedScrollOffset are in physical
622+
// space at this point, so the comparison is correct.
605623
if (
606624
this._intendedScrollOffset !== null &&
607625
Math.abs(offset - this._intendedScrollOffset) < 1.5
@@ -610,13 +628,19 @@ export class Virtualizer<
610628
}
611629
this._intendedScrollOffset = null
612630

631+
// Convert physical scroll offset to virtual coordinate space.
632+
// All internal state (scrollOffset, scrollDirection, etc.) operates
633+
// in virtual space.
634+
const scale = this.getScale()
635+
const virtualOffset = offset * scale
636+
613637
this.scrollAdjustments = 0
614638
this.scrollDirection = isScrolling
615-
? this.getScrollOffset() < offset
639+
? this.getScrollOffset() < virtualOffset
616640
? 'forward'
617641
: 'backward'
618642
: null
619-
this.scrollOffset = offset
643+
this.scrollOffset = virtualOffset
620644
this.isScrolling = isScrolling
621645

622646
// Flush deferred iOS adjustments if we're now fully settled.
@@ -750,13 +774,19 @@ export class Virtualizer<
750774
: this.scrollState.lastTargetOffset
751775

752776
// Require one stable frame where target matches scroll offset.
753-
// approxEqual() already tolerates minor fluctuations, so one frame is sufficient
754-
// to confirm scroll has reached its target without premature cleanup.
777+
// The tolerance accounts for subpixel browser rounding, so one frame is
778+
// sufficient to confirm scroll has reached its target.
755779
const STABLE_FRAMES = 1
756780

757781
const targetChanged = targetOffset !== this.scrollState.lastTargetOffset
758782

759-
if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
783+
// When scroll-scaling is active, a 1px physical browser rounding error
784+
// maps to `scale` px in virtual space. Scale the tolerance accordingly
785+
// so the reconcile loop can settle.
786+
const scale = this.getScale()
787+
const tolerance = scale > 1 ? scale * 1.5 : 1.01
788+
789+
if (!targetChanged && Math.abs(targetOffset - this.getScrollOffset()) < tolerance) {
760790
this.scrollState.stableFrames++
761791
if (this.scrollState.stableFrames >= STABLE_FRAMES) {
762792
// Final-pass exact landing. The reconcile-stable check uses a 1.01px
@@ -1344,12 +1374,26 @@ export class Virtualizer<
13441374
() => [this.getVirtualIndexes(), this.getMeasurements()],
13451375
(indexes, measurements) => {
13461376
const virtualItems: Array<VirtualItem> = []
1377+
const scale = this.getScale()
13471378

13481379
for (let k = 0, len = indexes.length; k < len; k++) {
13491380
const i = indexes[k]!
13501381
const measurement = measurements[i]!
13511382

1352-
virtualItems.push(measurement)
1383+
if (scale === 1) {
1384+
// No scaling — push reference directly (zero overhead, same as before)
1385+
virtualItems.push(measurement)
1386+
} else {
1387+
// Scaling active — create physical copy for consumer positioning
1388+
virtualItems.push({
1389+
key: measurement.key,
1390+
index: measurement.index,
1391+
start: measurement.start / scale,
1392+
end: measurement.end / scale,
1393+
size: measurement.size / scale,
1394+
lane: measurement.lane,
1395+
})
1396+
}
13531397
}
13541398

13551399
return virtualItems
@@ -1384,18 +1428,21 @@ export class Virtualizer<
13841428
private getMaxScrollOffset = () => {
13851429
if (!this.scrollElement) return 0
13861430

1431+
let physicalMax: number
13871432
if ('scrollHeight' in this.scrollElement) {
13881433
// Element
1389-
return this.options.horizontal
1434+
physicalMax = this.options.horizontal
13901435
? this.scrollElement.scrollWidth - this.scrollElement.clientWidth
13911436
: this.scrollElement.scrollHeight - this.scrollElement.clientHeight
13921437
} else {
13931438
// Window
13941439
const doc = this.scrollElement.document.documentElement
1395-
return this.options.horizontal
1440+
physicalMax = this.options.horizontal
13961441
? doc.scrollWidth - this.scrollElement.innerWidth
13971442
: doc.scrollHeight - this.scrollElement.innerHeight
13981443
}
1444+
// Upscale physical DOM value to virtual coordinate space
1445+
return physicalMax * this.getScale()
13991446
}
14001447

14011448
getOffsetForAlignment = (
@@ -1533,7 +1580,7 @@ export class Virtualizer<
15331580
this.scheduleScrollReconcile()
15341581
}
15351582

1536-
getTotalSize = () => {
1583+
private getTotalVirtualSize = () => {
15371584
const measurements = this.getMeasurements()
15381585

15391586
let end: number
@@ -1574,6 +1621,10 @@ export class Virtualizer<
15741621
)
15751622
}
15761623

1624+
getTotalSize = () => {
1625+
return this.getTotalVirtualSize() / this.getScale()
1626+
}
1627+
15771628
/**
15781629
* Returns a snapshot of currently-measured items suitable for round-
15791630
* tripping through state storage (sessionStorage, history, etc.) and
@@ -1617,10 +1668,15 @@ export class Virtualizer<
16171668
behavior: ScrollBehavior | undefined
16181669
},
16191670
) => {
1620-
// Record the intended logical scroll target so the next scroll event
1671+
// Convert virtual coordinates to physical for the DOM write.
1672+
const scale = this.getScale()
1673+
const physicalOffset = offset / scale
1674+
const physicalAdj = adjustments != null ? adjustments / scale : undefined
1675+
// Record the intended physical scroll target so the next scroll event
16211676
// can reconcile against subpixel rounding by the browser.
1622-
this._intendedScrollOffset = offset + (adjustments ?? 0)
1623-
this.options.scrollToFn(offset, { behavior, adjustments }, this)
1677+
// _intendedScrollOffset is physical (compared against physical scrollTop readback).
1678+
this._intendedScrollOffset = physicalOffset + (physicalAdj ?? 0)
1679+
this.options.scrollToFn(physicalOffset, { behavior, adjustments: physicalAdj }, this)
16241680
}
16251681

16261682
measure = () => {

0 commit comments

Comments
 (0)