diff --git a/.changeset/scroll-scaling.md b/.changeset/scroll-scaling.md
new file mode 100644
index 00000000..e3f2bf12
--- /dev/null
+++ b/.changeset/scroll-scaling.md
@@ -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.
diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md
index a7af4d63..5d9f9412 100644
--- a/docs/api/virtualizer.md
+++ b/docs/api/virtualizer.md
@@ -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:
+
{/* capped at ~33M */}
+ {virtualizer.getVirtualItems().map(item => (
+
{/* physical */}
+ ...
+
+ ))}
+
+```
+
### `isScrollingResetDelay`
```tsx
@@ -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
@@ -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`.
diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts
index a1e9dcc7..976c2b25 100644
--- a/packages/virtual-core/src/index.ts
+++ b/packages/virtual-core/src/index.ts
@@ -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 = {
@@ -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
+ }
+
+ /** 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.
@@ -504,6 +521,7 @@ export class Virtualizer<
useScrollendEvent: false,
useAnimationFrameWithResizeObserver: false,
laneAssignmentMode: 'estimate',
+ maxScrollSize: 33_000_000,
} as unknown as Required>
for (const key in opts) {
@@ -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
@@ -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.
@@ -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
@@ -1344,12 +1379,26 @@ export class Virtualizer<
() => [this.getVirtualIndexes(), this.getMeasurements()],
(indexes, measurements) => {
const virtualItems: Array = []
+ 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
@@ -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 = (
@@ -1533,7 +1585,7 @@ export class Virtualizer<
this.scheduleScrollReconcile()
}
- getTotalSize = () => {
+ private getTotalVirtualSize = () => {
const measurements = this.getMeasurements()
let end: number
@@ -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
@@ -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 = () => {
diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts
index d49db8aa..d364f553 100644
--- a/packages/virtual-core/tests/index.test.ts
+++ b/packages/virtual-core/tests/index.test.ts
@@ -2526,3 +2526,341 @@ test('observeWindowOffset: reads scrollX when horizontal', () => {
listeners.get('scroll')!({} as Event)
expect(cb).toHaveBeenCalledWith(75, true)
})
+
+// ─── Scroll-scaling tests ────────────────────────────────────────────────────
+
+test('scale should be 1 when total size is under maxScrollSize', () => {
+ const virtualizer = new Virtualizer({
+ count: 100,
+ estimateSize: () => 50,
+ getScrollElement: () => null,
+ scrollToFn: vi.fn(),
+ observeElementRect: vi.fn(),
+ observeElementOffset: vi.fn(),
+ })
+
+ // 100 × 50 = 5000, well under 33M default
+ expect(virtualizer.scale).toBe(1)
+ expect(virtualizer.getTotalSize()).toBe(5000)
+})
+
+test('scale should activate when total size exceeds maxScrollSize', () => {
+ const virtualizer = new Virtualizer({
+ count: 1_000_000,
+ estimateSize: () => 40,
+ getScrollElement: () => null,
+ scrollToFn: vi.fn(),
+ observeElementRect: vi.fn(),
+ observeElementOffset: vi.fn(),
+ })
+
+ // 1M × 40 = 40M, exceeds 33M default
+ expect(virtualizer.scale).toBeGreaterThan(1)
+ expect(virtualizer.scale).toBeCloseTo(40_000_000 / 33_000_000, 5)
+ expect(virtualizer.getTotalSize()).toBeLessThanOrEqual(33_000_000)
+ expect(virtualizer.getTotalSize()).toBeCloseTo(33_000_000, -2)
+})
+
+test('maxScrollSize: Infinity should disable scaling entirely', () => {
+ const virtualizer = new Virtualizer({
+ count: 1_000_000,
+ estimateSize: () => 40,
+ maxScrollSize: Infinity,
+ getScrollElement: () => null,
+ scrollToFn: vi.fn(),
+ observeElementRect: vi.fn(),
+ observeElementOffset: vi.fn(),
+ })
+
+ expect(virtualizer.scale).toBe(1)
+ expect(virtualizer.getTotalSize()).toBe(40_000_000)
+})
+
+test('custom maxScrollSize should control the scaling threshold', () => {
+ const virtualizer = new Virtualizer({
+ count: 100,
+ estimateSize: () => 50,
+ maxScrollSize: 2000,
+ getScrollElement: () => null,
+ scrollToFn: vi.fn(),
+ observeElementRect: vi.fn(),
+ observeElementOffset: vi.fn(),
+ })
+
+ // 100 × 50 = 5000 > 2000 custom max
+ expect(virtualizer.scale).toBeCloseTo(5000 / 2000, 5)
+ expect(virtualizer.getTotalSize()).toBeCloseTo(2000, 0)
+})
+
+test('getVirtualItems should return physical coordinates when scaling is active', () => {
+ const mockScrollElement = {
+ scrollTop: 0,
+ scrollLeft: 0,
+ scrollWidth: 400,
+ scrollHeight: 1000,
+ clientWidth: 400,
+ clientHeight: 400,
+ offsetWidth: 400,
+ offsetHeight: 400,
+ ownerDocument: {
+ defaultView: {
+ requestAnimationFrame: vi.fn(),
+ cancelAnimationFrame: vi.fn(),
+ performance: { now: () => Date.now() },
+ ResizeObserver: vi.fn(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+ })),
+ },
+ },
+ scrollTo: vi.fn(),
+ } as unknown as HTMLDivElement
+
+ const virtualizer = new Virtualizer({
+ count: 100,
+ estimateSize: () => 50,
+ maxScrollSize: 2000, // Force scaling: 100×50=5000 > 2000
+ getScrollElement: () => mockScrollElement,
+ scrollToFn: vi.fn(),
+ observeElementRect: (_instance, cb) => {
+ cb({ width: 400, height: 400 })
+ return () => {}
+ },
+ observeElementOffset: (_instance, cb) => {
+ cb(0, false)
+ return () => {}
+ },
+ })
+
+ virtualizer._willUpdate()
+
+ const items = virtualizer.getVirtualItems()
+ expect(items.length).toBeGreaterThan(0)
+
+ const scale = virtualizer.scale
+ expect(scale).toBeGreaterThan(1)
+
+ // Internal measurements should be virtual
+ const measurements = virtualizer['getMeasurements']()
+ const firstMeasurement = measurements[0]!
+
+ // Consumer items should have physical (downscaled) coordinates
+ const firstItem = items[0]!
+ expect(firstItem.start).toBeCloseTo(firstMeasurement.start / scale, 5)
+ expect(firstItem.size).toBeCloseTo(firstMeasurement.size / scale, 5)
+ expect(firstItem.end).toBeCloseTo(firstMeasurement.end / scale, 5)
+
+ // Identity fields should be unchanged
+ expect(firstItem.index).toBe(firstMeasurement.index)
+ expect(firstItem.key).toBe(firstMeasurement.key)
+ expect(firstItem.lane).toBe(firstMeasurement.lane)
+
+ // When scaling is active, items must be new objects (not same reference)
+ expect(firstItem).not.toBe(firstMeasurement)
+})
+
+test('getVirtualItems should return references (not copies) when scale is 1', () => {
+ const mockScrollElement = {
+ scrollTop: 0,
+ scrollLeft: 0,
+ scrollWidth: 400,
+ scrollHeight: 500,
+ clientWidth: 400,
+ clientHeight: 400,
+ offsetWidth: 400,
+ offsetHeight: 400,
+ ownerDocument: {
+ defaultView: {
+ requestAnimationFrame: vi.fn(),
+ cancelAnimationFrame: vi.fn(),
+ performance: { now: () => Date.now() },
+ ResizeObserver: vi.fn(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+ })),
+ },
+ },
+ scrollTo: vi.fn(),
+ } as unknown as HTMLDivElement
+
+ const virtualizer = new Virtualizer({
+ count: 10,
+ estimateSize: () => 50,
+ getScrollElement: () => mockScrollElement,
+ scrollToFn: vi.fn(),
+ observeElementRect: (_instance, cb) => {
+ cb({ width: 400, height: 400 })
+ return () => {}
+ },
+ observeElementOffset: (_instance, cb) => {
+ cb(0, false)
+ return () => {}
+ },
+ })
+
+ virtualizer._willUpdate()
+
+ expect(virtualizer.scale).toBe(1)
+
+ const measurements = virtualizer['getMeasurements']()
+ const items = virtualizer.getVirtualItems()
+
+ expect(items.length).toBeGreaterThan(0)
+
+ // Verify strict reference equality (===) between returned items and measurements
+ items.forEach((item) => {
+ expect(item).toBe(measurements[item.index])
+ })
+})
+
+test('scroll offset should be upscaled from physical to virtual', () => {
+ let offsetCallback: ((offset: number, isScrolling: boolean) => void) | null =
+ null
+
+ const virtualizer = new Virtualizer({
+ count: 100,
+ estimateSize: () => 50,
+ maxScrollSize: 2000, // scale = 5000/2000 = 2.5
+ getScrollElement: () =>
+ ({
+ scrollTop: 0,
+ offsetWidth: 400,
+ offsetHeight: 400,
+ ownerDocument: {
+ defaultView: {
+ requestAnimationFrame: vi.fn(),
+ cancelAnimationFrame: vi.fn(),
+ performance: { now: () => Date.now() },
+ ResizeObserver: vi.fn(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+ })),
+ },
+ },
+ scrollTo: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ }) as unknown as HTMLDivElement,
+ scrollToFn: vi.fn(),
+ observeElementRect: (_instance, cb) => {
+ cb({ width: 400, height: 400 })
+ return () => {}
+ },
+ observeElementOffset: (_instance, cb) => {
+ offsetCallback = cb
+ return () => {}
+ },
+ })
+
+ virtualizer._willUpdate()
+
+ // Simulate a physical scroll to 1000px
+ offsetCallback!(1000, true)
+
+ // Internal scroll offset should be virtual (upscaled)
+ const scale = virtualizer.scale
+ expect(virtualizer.scrollOffset).toBeCloseTo(1000 * scale, 5)
+})
+
+test('_scrollToOffset should downscale virtual to physical', () => {
+ const scrollToFn = vi.fn()
+
+ const virtualizer = new Virtualizer({
+ count: 100,
+ estimateSize: () => 50,
+ maxScrollSize: 2000, // scale = 2.5
+ getScrollElement: () => null,
+ scrollToFn,
+ observeElementRect: vi.fn(),
+ observeElementOffset: vi.fn(),
+ })
+
+ // Call the private _scrollToOffset with a virtual offset
+ const scale = virtualizer.scale
+ const virtualOffset = 2500
+ virtualizer['_scrollToOffset'](virtualOffset, {
+ adjustments: undefined,
+ behavior: undefined,
+ })
+
+ // scrollToFn should receive physical offset
+ expect(scrollToFn).toHaveBeenCalled()
+ const physicalOffset = scrollToFn.mock.calls[0]![0]
+ expect(physicalOffset).toBeCloseTo(virtualOffset / scale, 5)
+})
+
+test('resize anchoring should work correctly with scaling active', () => {
+ const scrollToFn = vi.fn()
+ let offsetCallback: ((offset: number, isScrolling: boolean) => void) | null =
+ null
+
+ const mockScrollElement = {
+ scrollTop: 0,
+ scrollLeft: 0,
+ scrollWidth: 400,
+ scrollHeight: 2000,
+ clientWidth: 400,
+ clientHeight: 400,
+ offsetWidth: 400,
+ offsetHeight: 400,
+ ownerDocument: {
+ defaultView: {
+ requestAnimationFrame: vi.fn(),
+ cancelAnimationFrame: vi.fn(),
+ performance: { now: () => Date.now() },
+ ResizeObserver: vi.fn(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+ })),
+ },
+ },
+ scrollTo: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ } as unknown as HTMLDivElement
+
+ const virtualizer = new Virtualizer({
+ count: 100,
+ estimateSize: () => 50,
+ maxScrollSize: 2000,
+ getScrollElement: () => mockScrollElement,
+ scrollToFn,
+ observeElementRect: (_instance, cb) => {
+ cb({ width: 400, height: 400 })
+ return () => {}
+ },
+ observeElementOffset: (_instance, cb) => {
+ offsetCallback = cb
+ return () => {}
+ },
+ })
+
+ virtualizer._willUpdate()
+
+ // Simulate scrolling to a position where item 0 is above the viewport
+ const scale = virtualizer.scale
+ const physicalScroll = 800
+ offsetCallback!(physicalScroll, false)
+
+ scrollToFn.mockClear()
+
+ // Resize item 0 (above viewport) — should trigger scroll anchoring
+ virtualizer.resizeItem(0, 100) // was 50, now 100, delta = +50
+
+ // scrollToFn should be called with adjusted physical offset
+ expect(scrollToFn).toHaveBeenCalled()
+ const calledPhysicalOffset = scrollToFn.mock.calls[0]![0] as number
+ const calledAdjustments = (scrollToFn.mock.calls[0]![1] as any)
+ ?.adjustments as number | undefined
+ const totalPhysical = calledPhysicalOffset + (calledAdjustments ?? 0)
+ // The adjustment (delta=50 in virtual, 50/scale in physical) should be reflected
+ expect(totalPhysical).toBeGreaterThan(physicalScroll)
+ expect(totalPhysical).toBeCloseTo(
+ physicalScroll + 50 / scale,
+ 0,
+ )
+})