Skip to content

Commit 1e6c48d

Browse files
committed
feat(virtual-core): iOS momentum-safe scroll adjustments via CSS offset
On iOS WebKit, writing scrollTop during momentum scroll cancels the in-flight scroll. Instead of writing scrollTop, apply a negative marginTop on the container element to visually compensate for above-viewport size changes. The CSS offset is flushed to a real scrollTop write once momentum fully settles. Changes: - Add CSS offset (marginTop) approach for iOS scroll adjustments - Defer adjustments through touch→momentum→settled lifecycle - Force-flush CSS offset before programmatic scroll operations - Compensate scrollOffset for CSS offset in range calculations - Guard against Safari elastic overscroll during flush - Clean up CSS offset on unmount - Refine backward-scroll suppression: first measurements always adjust (needed for prepend), re-measurements skip during backward scroll (avoids scrollTop cascade jank) - Add overflow-anchor: none to chat example CSS - Update chat docs with iOS section and getItemKey best practices
1 parent 932c358 commit 1e6c48d

5 files changed

Lines changed: 159 additions & 24 deletions

File tree

.changeset/ios-momentum-scroll.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@tanstack/virtual-core': minor
3+
---
4+
5+
feat: iOS momentum-safe scroll adjustments via CSS offset
6+
7+
On iOS WebKit, writing `scrollTop` during momentum scroll cancels the in-flight scroll. Instead of writing `scrollTop`, we now apply a negative `marginTop` on the container element to visually compensate for above-viewport size changes. The CSS offset is flushed to a real `scrollTop` write once momentum fully settles.
8+
9+
- Defer scroll adjustments during iOS touch and momentum phases using CSS offset (`marginTop`/`marginLeft`)
10+
- Force-flush CSS offset before programmatic scroll operations (`scrollToIndex`, `scrollToOffset`, `scrollBy`)
11+
- Compensate `scrollOffset` for active CSS offset in range calculations
12+
- Guard against Safari elastic overscroll (rubber-band) during flush
13+
- Clean up CSS offset on unmount
14+
- Refine backward-scroll suppression: first measurements always adjust regardless of direction; re-measurements skip during backward scroll to avoid the `scrollTop` cascade jank

docs/chat.md

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ const virtualizer = useVirtualizer({
1111
count: messages.length,
1212
getScrollElement: () => parentRef.current,
1313
estimateSize: () => 72,
14-
getItemKey: (index) => messages[index]!.id,
14+
getItemKey: React.useCallback(
15+
(index: number) => messages[index]!.id,
16+
[messages],
17+
),
1518
anchorTo: 'end',
1619
followOnAppend: true,
1720
scrollEndThreshold: 80,
@@ -46,9 +49,14 @@ setMessages((current) => [...olderMessages, ...current])
4649
Stable keys are required for this to work:
4750

4851
```tsx
49-
getItemKey: (index) => messages[index]!.id
52+
getItemKey: React.useCallback(
53+
(index: number) => messages[index]!.id,
54+
[messages],
55+
),
5056
```
5157

58+
Wrap getItemKey in useCallback so the virtualizer maintains a stable getItemKey reference. Without it, a new function identity can cause memoized measurement options to be recomputed, leading to unnecessary measurement rebuilds/cache invalidation.
59+
5260
Do not use index keys for chat history. After a prepend, every existing message shifts to a new index, so index keys cannot identify the same message across the update.
5361

5462
### Follow appended output only when pinned
@@ -127,14 +135,23 @@ Use a normal scroll container and normal item order. You do not need `flex-direc
127135

128136
## Production Checklist
129137

130-
- Use stable message ids with `getItemKey`.
138+
- Use stable message ids with `getItemKey`, wrapped in `useCallback`.
131139
- Give the scroll element a fixed height and `overflow: auto`.
140+
- Set `overflow-anchor: none` on the scroll element. Browsers that support native scroll anchoring (Chrome, Firefox) will otherwise fight the virtualizer's own offset adjustments on prepend, causing jumps. Safari does not support `overflow-anchor`, so this has no effect there.
132141
- Call `measureElement` for dynamic message heights.
133142
- Use `anchorTo: 'end'` for prepend stability and streaming bottom growth.
134143
- Use `followOnAppend` when new output should follow only from the latest position.
135144
- Use `isAtEnd()` to show "Jump to latest" UI when the user is reading history.
136145
- Keep network loading state outside the virtualizer; prepend or append data normally.
137146

147+
## iOS Safari
148+
149+
iOS WebKit cancels momentum (inertia) scrolling whenever JavaScript writes to `scrollTop` or calls `scrollTo()`. This is a platform limitation — there is no opt-out. Since prepend anchoring and item-resize compensation both need to adjust the scroll position, a naïve implementation would kill the scroll mid-flick, making the list feel broken on iPhones and iPads.
150+
151+
TanStack Virtual works around this with a **CSS offset**: when a scroll adjustment is needed during an active touch or momentum phase, the virtualizer applies a negative `marginTop` on the container element instead of writing `scrollTop`. This shifts the content visually without touching the scroll position, so momentum continues uninterrupted. Once the scroll fully settles (no touch, no momentum, no elastic overscroll), the virtualizer flushes the accumulated CSS offset into a single `scrollTop` write and clears the margin.
152+
153+
No extra configuration is needed — this is handled automatically on iOS.
154+
138155
## API Reference
139156

140157
- [`anchorTo`](api/virtualizer#anchorto)

examples/react/chat/src/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ button:hover {
7070
.Messages {
7171
min-height: 0;
7272
overflow: auto;
73+
overflow-anchor: none;
7374
width: 100%;
7475
}
7576

packages/virtual-core/src/index.ts

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,7 @@ export class Virtualizer<
687687
(this.isScrolling || this._iosTouching || this._iosJustTouchEnded)
688688
) {
689689
this._iosDeferredAdjustment += delta
690+
this._applyIosCssOffset()
690691
} else {
691692
this._scrollToOffset(this.getScrollOffset(), {
692693
adjustments: (this.scrollAdjustments += delta),
@@ -695,6 +696,25 @@ export class Virtualizer<
695696
}
696697
}
697698

699+
// Apply CSS compensation for deferred iOS scroll adjustments.
700+
// Uses negative marginTop (or marginLeft for horizontal) on the scroll
701+
// element's first child (the container) to visually shift items without
702+
// writing scrollTop — which would cancel iOS momentum scroll.
703+
// Cleared when _flushIosDeferredIfReady writes the real scroll position.
704+
private _applyIosCssOffset = () => {
705+
if (!this.scrollElement || !(this.scrollElement instanceof Element)) return
706+
// TODO: accept a containerElement option so frameworks can pass the
707+
// inner container directly instead of relying on firstElementChild.
708+
const container = this.scrollElement.firstElementChild as HTMLElement | null
709+
if (!container) return
710+
const prop = this.options.horizontal ? 'marginLeft' : 'marginTop'
711+
if (this._iosDeferredAdjustment === 0) {
712+
container.style[prop] = ''
713+
} else {
714+
container.style[prop] = `${-this._iosDeferredAdjustment}px`
715+
}
716+
}
717+
698718
private maybeNotify = memo(
699719
() => {
700720
this.calculateRange()
@@ -719,7 +739,28 @@ export class Virtualizer<
719739
},
720740
)
721741

742+
// Force-flush any active iOS CSS offset. Unlike _flushIosDeferredIfReady
743+
// this skips the settling guards — used before public scroll operations
744+
// (scrollToIndex, scrollToEnd, etc.) and cleanup.
745+
private _forceFlushIosCssOffset = () => {
746+
if (this._iosDeferredAdjustment === 0) return
747+
const rawBrowserScroll =
748+
(this.scrollOffset ?? 0) - this._iosDeferredAdjustment
749+
const delta = this._iosDeferredAdjustment
750+
this._iosDeferredAdjustment = 0
751+
this._applyIosCssOffset()
752+
this._scrollToOffset(rawBrowserScroll, {
753+
adjustments: (this.scrollAdjustments += delta),
754+
behavior: undefined,
755+
})
756+
}
757+
722758
private cleanup = () => {
759+
// Clear CSS offset before losing the scrollElement reference.
760+
if (this._iosDeferredAdjustment !== 0) {
761+
this._iosDeferredAdjustment = 0
762+
this._applyIosCssOffset()
763+
}
723764
this.unsubs.filter(Boolean).forEach((d) => d!())
724765
this.unsubs = []
725766
this.observer.disconnect()
@@ -787,6 +828,14 @@ export class Virtualizer<
787828
}
788829
this._intendedScrollOffset = null
789830

831+
// Compensate for iOS CSS offset: the browser's scrollTop is
832+
// the real position, but items are visually shifted by the
833+
// deferred adjustment via CSS marginTop. Add the deferred
834+
// amount so range calculations match the visual state.
835+
if (this._iosDeferredAdjustment !== 0) {
836+
offset += this._iosDeferredAdjustment
837+
}
838+
790839
this.scrollAdjustments = 0
791840
this.scrollDirection = isScrolling
792841
? this.getScrollOffset() < offset
@@ -877,15 +926,18 @@ export class Virtualizer<
877926
// Skip when followOnAppend is set — scrollToEnd will handle it.
878927
//
879928
// On iOS WebKit, writing scrollTop during touch/momentum cancels
880-
// the in-flight scroll. Defer the DOM sync the same way
881-
// applyScrollAdjustment does — accumulate the delta and let
882-
// _flushIosDeferredIfReady handle it once the scroll settles.
929+
// the in-flight scroll. Instead of writing scrollTop, apply a
930+
// CSS offset (negative marginTop on the container) that
931+
// visually compensates for the stale browser scroll position.
932+
// The CSS offset is flushed to a real scrollTop write once
933+
// momentum settles (_flushIosDeferredIfReady).
883934
if (
884935
isIOSWebKit() &&
885936
(this.isScrolling || this._iosTouching || this._iosJustTouchEnded)
886937
) {
887938
if (anchorDelta !== 0) {
888939
this._iosDeferredAdjustment += anchorDelta
940+
this._applyIosCssOffset()
889941
}
890942
} else {
891943
this._scrollToOffset(this.getScrollOffset(), {
@@ -915,11 +967,16 @@ export class Virtualizer<
915967
// while in that zone snaps the page back to the clamped value at the
916968
// end of the bounce, often discarding the user's intent. Skip the
917969
// flush; the next in-bounds scroll event will retry.
918-
const cur = this.getScrollOffset()
970+
// Use the raw browser scroll position (scrollOffset includes the
971+
// CSS offset compensation, so subtract it back out).
972+
const cur = (this.scrollOffset ?? 0) - this._iosDeferredAdjustment
919973
const max = this.getMaxScrollOffset()
920974
if (cur < 0 || cur > max) return
921975
const delta = this._iosDeferredAdjustment
922976
this._iosDeferredAdjustment = 0
977+
// Clear the CSS offset (negative marginTop) before writing the
978+
// real scroll position.
979+
this._applyIosCssOffset()
923980
// Roll the deferred delta into the running accumulator so any resize
924981
// landing between now and the resulting scroll event computes from the
925982
// post-flush offset rather than the stale one.
@@ -1519,14 +1576,15 @@ export class Virtualizer<
15191576
delta,
15201577
this,
15211578
)
1522-
: // Default: adjust scrollTop only when the resize is an above-
1523-
// viewport item AND we're not actively scrolling backward.
1524-
// Adjusting during backward scroll fights the user's scroll
1525-
// direction and produces the "items jump while scrolling up"
1526-
// jank reported across many issues. Users who want the old
1527-
// behavior can pass shouldAdjustScrollPositionOnItemSizeChange.
1579+
: // Default: adjust when the resize is an above-viewport item.
1580+
// First measurement (!has(key)): always adjust — the item
1581+
// has never been sized, so the estimate→actual delta must
1582+
// be compensated regardless of scroll direction.
1583+
// Re-measurement (has(key)): skip during backward scroll
1584+
// to avoid the "items jump while scrolling up" cascade.
15281585
itemStart < this.getScrollOffset() + this.scrollAdjustments &&
1529-
this.scrollDirection !== 'backward')
1586+
(!this.itemSizeCache.has(key) ||
1587+
this.scrollDirection !== 'backward'))
15301588

15311589
if (this.pendingMin === null || index < this.pendingMin) {
15321590
this.pendingMin = index
@@ -1684,6 +1742,7 @@ export class Virtualizer<
16841742
toOffset: number,
16851743
{ align = 'start', behavior = 'auto' }: ScrollToOffsetOptions = {},
16861744
) => {
1745+
this._forceFlushIosCssOffset()
16871746
const offset = this.getOffsetForAlignment(toOffset, align)
16881747

16891748
const now = this.now()
@@ -1708,6 +1767,7 @@ export class Virtualizer<
17081767
behavior = 'auto',
17091768
}: ScrollToIndexOptions = {},
17101769
) => {
1770+
this._forceFlushIosCssOffset()
17111771
index = Math.max(0, Math.min(index, this.options.count - 1))
17121772

17131773
const offsetInfo = this.getOffsetForIndex(index, initialAlign)
@@ -1735,6 +1795,7 @@ export class Virtualizer<
17351795
delta: number,
17361796
{ behavior = 'auto' }: ScrollToOffsetOptions = {},
17371797
) => {
1798+
this._forceFlushIosCssOffset()
17381799
const offset = this.getScrollOffset() + delta
17391800
const now = this.now()
17401801

packages/virtual-core/tests/index.test.ts

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2066,10 +2066,10 @@ test('non-iOS: adjustment is applied immediately during scroll (no regression)',
20662066
expect(v['_iosDeferredAdjustment']).toBe(0)
20672067
})
20682068

2069-
test('scroll-up jank: backward-scroll skips scroll-position adjustment by default', () => {
2070-
// Default behavior change: when an above-viewport item resizes while the
2071-
// user is scrolling BACKWARD, we no longer write to scrollTop. This avoids
2072-
// the well-known "items jump while scrolling up" jank.
2069+
test('scroll adjustment: backward-scroll adjusts on first measurement (no cache)', () => {
2070+
// First measurement (item not in itemSizeCache) always adjusts,
2071+
// even during backward scroll — the estimate→actual delta must
2072+
// be compensated.
20732073
const scrollToFn = vi.fn()
20742074
let scrollCb: ((o: number, s: boolean) => void) | null = null
20752075
const v = new Virtualizer({
@@ -2087,26 +2087,68 @@ test('scroll-up jank: backward-scroll skips scroll-position adjustment by defaul
20872087
observeElementRect: () => {},
20882088
observeElementOffset: (_inst, cb) => {
20892089
scrollCb = cb
2090-
// Simulate user starting at scrollTop=200, then scrolling up to 100.
20912090
cb(200, false)
20922091
return () => {}
20932092
},
20942093
})
20952094
v._willUpdate()
20962095
v['getMeasurements']()
2097-
// Now simulate backward scroll: from 200 to 100 (offset decreases).
2096+
// Backward scroll: from 200 to 100.
20982097
scrollCb!(100, true)
20992098
expect(v.scrollDirection).toBe('backward')
21002099
scrollToFn.mockClear()
21012100

2102-
// Resize an above-viewport item while scrolling backward.
2101+
// First measurement of item 0 (not in cache) while scrolling backward.
21032102
v.resizeItem(0, 100) // item 0 grows by 50px
21042103

2105-
// Default behavior: no scroll-position adjustment fires.
2104+
// First measurement: adjustment fires even during backward scroll.
2105+
expect(scrollToFn).toHaveBeenCalled()
2106+
})
2107+
2108+
test('scroll adjustment: backward-scroll skips re-measurement (has cache)', () => {
2109+
// Re-measurement (item already in itemSizeCache) skips during
2110+
// backward scroll to avoid the scrollTop cascade jank.
2111+
const scrollToFn = vi.fn()
2112+
let scrollCb: ((o: number, s: boolean) => void) | null = null
2113+
const v = new Virtualizer({
2114+
count: 10,
2115+
estimateSize: () => 50,
2116+
getScrollElement: () =>
2117+
({
2118+
scrollTop: 200,
2119+
scrollLeft: 0,
2120+
scrollHeight: 500,
2121+
clientHeight: 200,
2122+
offsetHeight: 200,
2123+
}) as any,
2124+
scrollToFn,
2125+
observeElementRect: () => {},
2126+
observeElementOffset: (_inst, cb) => {
2127+
scrollCb = cb
2128+
cb(200, false)
2129+
return () => {}
2130+
},
2131+
})
2132+
v._willUpdate()
2133+
v['getMeasurements']()
2134+
2135+
// First measurement while idle — populates the cache.
2136+
v.resizeItem(0, 80)
2137+
scrollToFn.mockClear()
2138+
2139+
// Now scroll backward.
2140+
scrollCb!(100, true)
2141+
expect(v.scrollDirection).toBe('backward')
2142+
scrollToFn.mockClear()
2143+
2144+
// Re-measurement during backward scroll.
2145+
v.resizeItem(0, 100) // grows again
2146+
2147+
// Re-measurement: no adjustment during backward scroll.
21062148
expect(scrollToFn).not.toHaveBeenCalled()
21072149
})
21082150

2109-
test('scroll-up jank: forward-scroll still applies adjustment (no regression)', () => {
2151+
test('scroll adjustment: forward-scroll applies adjustment', () => {
21102152
const scrollToFn = vi.fn()
21112153
let scrollCb: ((o: number, s: boolean) => void) | null = null
21122154
const v = new Virtualizer({
@@ -2141,7 +2183,7 @@ test('scroll-up jank: forward-scroll still applies adjustment (no regression)',
21412183
expect(scrollToFn).toHaveBeenCalled()
21422184
})
21432185

2144-
test('scroll-up jank: idle (scrollDirection=null) still applies adjustment', () => {
2186+
test('scroll adjustment: idle (scrollDirection=null) applies adjustment', () => {
21452187
// When not actively scrolling, adjustment still fires — needed for the
21462188
// mount-time measurement storm where items measure before any scroll.
21472189
const scrollToFn = vi.fn()

0 commit comments

Comments
 (0)