Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/scroll-scaling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@tanstack/virtual-core': minor
---

feat: Add built-in scroll-scaling to bypass browser scroll height limits

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.

**New option:**
- `maxScrollSize` — Maximum physical scroll container size in pixels. Set to `Infinity` to disable scaling. Default: `33_000_000`.

**New property:**
- `scale` — The current scale factor (1 when no scaling is active).

When scaling is active:
- `getTotalSize()` returns the capped physical size for use as the container's CSS height/width.
- `getVirtualItems()` returns items with physical coordinates — use `item.start` directly for positioning.
- `scrollToIndex()`, `scrollToOffset()`, and `scrollBy()` work transparently.
- Scroll anchoring (resize adjustments for items above the viewport) works correctly through the scale transform.

When scaling is **not** active (the vast majority of use cases), there is zero overhead — the existing code paths are unchanged.

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.
51 changes: 51 additions & 0 deletions docs/api/virtualizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,43 @@ Controls when lane assignments are cached in a masonry layout.
- `'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.
- `'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.

### `maxScrollSize`

```tsx
maxScrollSize?: number
```

**Default**: `33_000_000`

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.

When scaling is active:
- `getTotalSize()` returns the capped physical size (use this for your container's CSS height/width)
- `getVirtualItems()` returns items with physical coordinates (use `item.start` directly for `translateY`/`translateX`)
- `scrollToIndex()` and `scrollToOffset()` work transparently
- The `scale` property reflects the current scale factor

Set to `Infinity` to disable scaling entirely.

```tsx
// Example: 1 million items at 40px each = 40M px (exceeds browser limit)
const virtualizer = useVirtualizer({
count: 1_000_000,
estimateSize: () => 40,
getScrollElement: () => parentRef.current,
// maxScrollSize defaults to 33M — scaling activates automatically
})

// Everything works as normal — no code changes needed:
<div style={{ height: virtualizer.getTotalSize() }}> {/* capped at ~33M */}
{virtualizer.getVirtualItems().map(item => (
<div style={{ transform: `translateY(${item.start}px)` }}> {/* physical */}
...
</div>
))}
</div>
```

### `isScrollingResetDelay`

```tsx
Expand Down Expand Up @@ -397,6 +434,8 @@ getTotalSize: () => number

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.

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.

### `measure`

```tsx
Expand Down Expand Up @@ -511,3 +550,15 @@ scrollOffset: number
```

This option represents the current scroll position along the scrolling axis. It is measured in pixels from the starting point of the scrollable area.

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.

### `scale`

```tsx
scale: number
```

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`.

You can use this to recover the real (unscaled) size of an item: `realSize = item.size * virtualizer.scale`.
89 changes: 77 additions & 12 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,10 @@ export interface VirtualizerOptions<
isRtl?: boolean
useAnimationFrameWithResizeObserver?: boolean
laneAssignmentMode?: LaneAssignmentMode
/** Maximum physical scroll container size in pixels. When the virtual total
* size exceeds this value, the virtualizer applies a scale factor to compress
* the scroll range. Defaults to 33_000_000. Set to Infinity to disable. */
maxScrollSize?: number
}

type ScrollState = {
Expand Down Expand Up @@ -378,6 +382,19 @@ export class Virtualizer<
scrollOffset: number | null = null
scrollDirection: ScrollDirection | null = null
private scrollAdjustments = 0

private getScale = (): number => {
const virtualTotal = this.getTotalVirtualSize()
const max = this.options.maxScrollSize
// Invalid values disable scaling to keep math stable.
if (!Number.isFinite(max) || max <= 0) return 1
return virtualTotal > max ? virtualTotal / max : 1
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/** Current scale factor. Returns 1 when no scaling is active. */
get scale(): number {
return this.getScale()
}
// Sum of size-change deltas above-viewport that were skipped during
// iOS momentum scroll (writing scrollTop mid-momentum cancels it).
// Flushed in a single scrollTo when iOS is fully settled.
Expand Down Expand Up @@ -504,6 +521,7 @@ export class Virtualizer<
useScrollendEvent: false,
useAnimationFrameWithResizeObserver: false,
laneAssignmentMode: 'estimate',
maxScrollSize: 33_000_000,
} as unknown as Required<VirtualizerOptions<TScrollElement, TItemElement>>

for (const key in opts) {
Expand Down Expand Up @@ -602,6 +620,8 @@ export class Virtualizer<
// self-write — by the time the user has moved 1.5 px, the
// intended value will already have been consumed by a prior
// scroll event and cleared.
// Note: both offset and _intendedScrollOffset are in physical
// space at this point, so the comparison is correct.
if (
this._intendedScrollOffset !== null &&
Math.abs(offset - this._intendedScrollOffset) < 1.5
Expand All @@ -610,13 +630,19 @@ export class Virtualizer<
}
this._intendedScrollOffset = null

// Convert physical scroll offset to virtual coordinate space.
// All internal state (scrollOffset, scrollDirection, etc.) operates
// in virtual space.
const scale = this.getScale()
const virtualOffset = offset * scale

this.scrollAdjustments = 0
this.scrollDirection = isScrolling
? this.getScrollOffset() < offset
? this.getScrollOffset() < virtualOffset
? 'forward'
: 'backward'
: null
this.scrollOffset = offset
this.scrollOffset = virtualOffset
this.isScrolling = isScrolling

// Flush deferred iOS adjustments if we're now fully settled.
Expand Down Expand Up @@ -750,13 +776,22 @@ export class Virtualizer<
: this.scrollState.lastTargetOffset

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

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

if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) {
// When scroll-scaling is active, a 1px physical browser rounding error
// maps to `scale` px in virtual space. Scale the tolerance accordingly
// so the reconcile loop can settle.
const scale = this.getScale()
const tolerance = scale > 1 ? scale * 1.5 : 1.01

if (
!targetChanged &&
Math.abs(targetOffset - this.getScrollOffset()) < tolerance
) {
this.scrollState.stableFrames++
if (this.scrollState.stableFrames >= STABLE_FRAMES) {
// Final-pass exact landing. The reconcile-stable check uses a 1.01px
Expand Down Expand Up @@ -1344,12 +1379,26 @@ export class Virtualizer<
() => [this.getVirtualIndexes(), this.getMeasurements()],
(indexes, measurements) => {
const virtualItems: Array<VirtualItem> = []
const scale = this.getScale()

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

virtualItems.push(measurement)
if (scale === 1) {
// No scaling — push reference directly (zero overhead, same as before)
virtualItems.push(measurement)
} else {
// Scaling active — create physical copy for consumer positioning
virtualItems.push({
key: measurement.key,
index: measurement.index,
start: measurement.start / scale,
end: measurement.end / scale,
size: measurement.size / scale,
lane: measurement.lane,
})
}
}

return virtualItems
Expand Down Expand Up @@ -1384,18 +1433,21 @@ export class Virtualizer<
private getMaxScrollOffset = () => {
if (!this.scrollElement) return 0

let physicalMax: number
if ('scrollHeight' in this.scrollElement) {
// Element
return this.options.horizontal
physicalMax = this.options.horizontal
? this.scrollElement.scrollWidth - this.scrollElement.clientWidth
: this.scrollElement.scrollHeight - this.scrollElement.clientHeight
} else {
// Window
const doc = this.scrollElement.document.documentElement
return this.options.horizontal
physicalMax = this.options.horizontal
? doc.scrollWidth - this.scrollElement.innerWidth
: doc.scrollHeight - this.scrollElement.innerHeight
}
// Upscale physical DOM value to virtual coordinate space
return physicalMax * this.getScale()
}

getOffsetForAlignment = (
Expand Down Expand Up @@ -1533,7 +1585,7 @@ export class Virtualizer<
this.scheduleScrollReconcile()
}

getTotalSize = () => {
private getTotalVirtualSize = () => {
const measurements = this.getMeasurements()

let end: number
Expand Down Expand Up @@ -1574,6 +1626,10 @@ export class Virtualizer<
)
}

getTotalSize = () => {
return this.getTotalVirtualSize() / this.getScale()
}

/**
* Returns a snapshot of currently-measured items suitable for round-
* tripping through state storage (sessionStorage, history, etc.) and
Expand Down Expand Up @@ -1617,10 +1673,19 @@ export class Virtualizer<
behavior: ScrollBehavior | undefined
},
) => {
// Record the intended logical scroll target so the next scroll event
// Convert virtual coordinates to physical for the DOM write.
const scale = this.getScale()
const physicalOffset = offset / scale
const physicalAdj = adjustments != null ? adjustments / scale : undefined
// Record the intended physical scroll target so the next scroll event
// can reconcile against subpixel rounding by the browser.
this._intendedScrollOffset = offset + (adjustments ?? 0)
this.options.scrollToFn(offset, { behavior, adjustments }, this)
// _intendedScrollOffset is physical (compared against physical scrollTop readback).
this._intendedScrollOffset = physicalOffset + (physicalAdj ?? 0)
this.options.scrollToFn(
physicalOffset,
{ behavior, adjustments: physicalAdj },
this,
)
}

measure = () => {
Expand Down
Loading