Scrolling up with dynamic heights stutters and jumps · Issue #659 · TanStack/virtual · GitHub
Skip to content

Scrolling up with dynamic heights stutters and jumps #659

Closed

Description

@nconfrey

Describe the bug

I have a feed of dynamic items, like iframes, photos, and text. When I scroll downward, everything works great and the scrolling is smooth, as measured items that increase in height push the other items down out of sight. However, when I scroll upwards, the performance is super stuttery and the items jump all over the place:

Untitled.mov

Your minimal, reproducible example

Code below:

Steps to reproduce

Create the virtualizer as normal:

const rowVirtualizer = useVirtualizer({
    count: itemCount,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,
    overscan: 2
  })

Then the feed:

const mainFeed = () => {
    return (
      <div
        style={{
          height: `${rowVirtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {rowVirtualizer.getVirtualItems().map((virtualRow) => {
          const post = loadedPosts[virtualRow.index]
          return (
            <div
              key={virtualRow.key}
              data-index={virtualRow.index}
              ref={rowVirtualizer.measureElement}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                transform: `translateY(${virtualRow.start - rowVirtualizer.options.scrollMargin}px)`,
                display: 'flex',
              }}
            >
              <div style={{ width: '100%' }}>
                <FeedItem post={post} />
              </div>
            </div>
          )
        })}
      </div>
    )
  }

Expected behavior

Scrolling upwards should be as smooth as scrolling downwards.

How often does this bug happen?

Every time

Screenshots or Videos

No response

Platform

macOS, Chrome

tanstack-virtual version

"@tanstack/react-virtual": "^3.0.1"

TypeScript version

No response

Additional context

The following code inside of the useVirtualizer fixes the issue:

    measureElement: (element, entry, instance) => {
      const direction = instance.scrollDirection
      if (direction === "forward" || direction === null) {
        return element.scrollHeight
      } else {
        // don't remeasure if we are scrolling up
        const indexKey = Number(element.getAttribute("data-index"))
        let cacheMeasurement = instance.itemSizeCache.get(indexKey)
        return cacheMeasurement
      }
    }

I propose that the above behavior is default, that items are not remeasured when scrolling upward.

Terms & Code of Conduct

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.

Activity

  1. piecyk commented on Feb 2, 2024

    @piecyk
    Collaborator

    hmm that is interesting, the scroll backward is bit tricky, we are measuring and adjusting scroll position to remove this jumping, and it was working great for us.

    Also keep in mind that when jumping to place in the list where we didn't scroll, when scrolling backward we only have estimated size that will break if we don't measure.

  2. titodarmawan commented on Feb 2, 2024

    @titodarmawan

    this happened to me too in Vue, my list only have 7 items and I already put my exact items height to estimateSize (776px). But in my case, not only the scroll up is stuttery, it loop back to previous item when I try to scroll up and create infinite loop of scrolling. It will look like the video below, in this video I try to scroll up but it loop me back to previous item

    Screen.Recording.2024-02-02.at.22.05.53.mov
  3. nconfrey commented on Feb 6, 2024

    @nconfrey
    Author

    hmm that is interesting, the scroll backward is bit tricky, we are measuring and adjusting scroll position to remove this jumping, and it was working great for us.

    Also keep in mind that when jumping to place in the list where we didn't scroll, when scrolling backward we only have estimated size that will break if we don't measure.

    @piecyk what does the code look like to adjust the scroll position?

  4. piecyk commented on Feb 6, 2024

    @piecyk
    Collaborator

    @nconfrey it's pretty simple, for elements above scroll offset we adjust scroll position with the difference

    https://github.com/TanStack/virtual/blob/main/packages/virtual-core/src/index.ts#L662-L673

  5. ReidCampbell commented on Feb 14, 2024

    @ReidCampbell

    Experiencing this too but only in iOS Safari.react-virtuoso suffers from this issue too.

  6. ISilviu commented on Mar 4, 2024

    @ISilviu

    Is there any solution to this?
    For example, would it be an option to skip the corrections?

  7. piecyk commented on Mar 4, 2024

    @piecyk
    Collaborator

    Is there any solution to this?

    Can you create an minimal reproducible example? It should work out of box, just testes dynamic example on safari and looks fine.

    For example, would it be an option to skip the corrections?

    Yes, for example passing your own elementScroll https://github.com/TanStack/virtual/blob/main/packages/virtual-core/src/index.ts#L207-L221 that will not add adjustments

  8. ISilviu commented on Mar 4, 2024

    @ISilviu

    Can you create an minimal reproducible example?

    Ok, working on it.

  9. ISilviu commented on Mar 4, 2024

    @ISilviu

    Yes, for example passing your own elementScroll https://github.com/TanStack/virtual/blob/main/packages/virtual-core/src/index.ts#L207-L221 that will not add adjustments

    Unfortunately, it still doesn't solve the problem. Will try to reproduce it in a sandbox.

  10. carrithers commented on Mar 21, 2024

    @carrithers

    I've got it kind of working with the following code. There are still rendering artefacts on mobile as elements are resized, but for our use case it's better than having no virtualisation. It needs a reasonably large overscan (i've got 20) to prevent empty space appearing at the top of the list on certain viewport widths. It's also necessary to kill the cache entirely. The better you can make your estimateSize method, the fewer rendering artefacts you'll get.

    export function VirtualInfiniteScroller<T>(props: VirtualInfiniteScrollerProps<T>) {
      const {
        rowData,
        renderRow,
        hasNextPage,
        fetchNextPage,
        isFetchingNextPage,
        estimateRowHeight,
        overscan,
      } = props;
    
      const listRef = useRef<HTMLDivElement | null>(null);
    
      const estimateHeightWithLoading = (index: number) => {
        if (index > rowData.length - 1) {
          return LOADING_ROW_HEIGHT;
        }
        return estimateRowHeight(index);
      };
    
      const virtualizer = useWindowVirtualizer({
        count: hasNextPage ? rowData.length + 1 : rowData.length,
        estimateSize: estimateHeightWithLoading,
        overscan: overscan ?? 20,
        scrollMargin: listRef.current?.offsetTop ?? 0,
      });
    
      const virtualItems = virtualizer.getVirtualItems();
    
      // Kill the cache entirely to prevent weird scrolling issues. This is a hack
      virtualizer.measurementsCache = [];
    
      useEffect(() => {
        const [lastItem] = [...virtualItems].reverse();
    
        if (!lastItem) {
          return;
        }
    
        if (
          hasNextPage &&
          !isFetchingNextPage &&
          lastItem.index >= rowData.length - 1
        ) {
          fetchNextPage();
        }
      }, [
        hasNextPage,
        fetchNextPage,
        rowData.length,
        isFetchingNextPage,
        virtualItems,
      ]);
    
      return (
        <div ref={listRef} className="List">
          <div
            style={{
              height: virtualizer.getTotalSize(),
              width: '100%',
              position: 'relative',
            }}
          >
            <div
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                transform: `translateY(${
                  (virtualItems[0]?.start || 0) - virtualizer.options.scrollMargin
                }px)`,
              }}
            >
              {virtualItems.map((virtualItem) => {
                return (
                  <div
                    key={virtualItem.key}
                    data-index={virtualItem.index}
                    ref={(el) => virtualizer.measureElement(el)}
                  >
                    {virtualItem.index > rowData.length - 1 ? (
                      <FlexSpinner />
                    ) : (
                      renderRow(virtualItem.index)
                    )}
                  </div>
                );
              })}
            </div>
          </div>
        </div>
      );
    }
  11. nuanyang233 commented on Apr 19, 2024

    @nuanyang233

    I am encountering the same issue with react-virtuoso as you are. Is there any update on this problem?

  12. piecyk commented on Apr 19, 2024

    @piecyk
    Collaborator

    I am encountering the same issue with react-virtuoso as you are. Is there any update on this problem?

    hmm as mention above the size changes when scrolling up that causes the jumping because of scrolling element size change. We can't really fix this on library size as it's tightly coupled with specific implementation

    Please use suggested workaround with custom measureElement

  13. piecyk commented on Apr 20, 2024

    @piecyk
    Collaborator

    Maybe caching total size when scrolling backward also should limit the jumping, overall please share examples then i can have a look.

  14. bukke101 commented on May 7, 2024

    @bukke101

    Has anyone managed to solve the issue of flickering when scrolling in reverse with react-virtuoso? I've tried using increaseViewportBy, overscan, and either itemSize or measureElement, but I'm still experiencing flickering/jumping while scrolling up.

  15. 16 remaining items

  16. michaelboyles commented on Jan 25, 2025

    @michaelboyles

    I was able to reduce jumpiness by reducing overscan.

    I had it set to 50 for items of height 24px = 1200px of overscan (and I believe that's in each direction...)

    In retrospect it was too much. I reduced to 10 and the jumpiness is almost gone. Not perfect, but much better.

  17. scwood commented on Apr 18, 2025

    @scwood

    I experienced this when trying to render a feed of variable height images, and trying to use virtualizer.measureElement. It would work scrolling down, but on scrolling up it would jump and stutter and become unusable, specifically on mobile webkit. The solution for me was to give each <img> an explicit CSS height, fortunately I know the dimensions of each image.

  18. olafsulich commented on Apr 22, 2025

    @olafsulich

    Removing the flex properties from the parent (with overflow: auto) did the trick for me

  19. dev-folks commented on May 13, 2025

    @dev-folks

    In my case, the scroll issue showed up after navigating to another page and then going back to the previous scroll position.
    Scrolling upwards felt really choppy and had no inertia.
    initialMeasurementsCache only covers part of the list, so it didn’t solve the issue for me.

    Here’s a small trick that helped:

    const { getSizeCache, setSizeCache } = useItemSizeCacheStore() // zustand store
    
    const rowVirtualizer = useVirtualizer({
      count: (allItems.length + (hasNextPage ? 1 : 0)),
      getScrollElement: () => wrapRef.current,
      estimateSize: () => 300,
      gap: 16,
      overscan: 10,
      scrollPaddingStart: 100,
      scrollPaddingEnd: 100,
      initialOffset: 0,
      lanes: 1,
      useAnimationFrameWithResizeObserver: true,
      initialMeasurementsCache: [],
    })
    
    useEffect(() => {
      // Restore cached sizes on mount
      if (getSizeCache().size > 0) {
        // @ts-expect-error: intentionally accessing internal property
        rowVirtualizer.itemSizeCache = new Map(getSizeCache())
      }
    
      // Save size cache on unmount
      return () => {
        // @ts-expect-error: intentionally accessing internal property
        setSizeCache(rowVirtualizer.itemSizeCache)
      }
    }, [])
  20. hienhaobn commented on May 28, 2025

    @hienhaobn

    The solution is to implement a custom measureElement function that avoids remeasuring elements when scrolling upward, using cached measurements instead

    Needs

    1. Implement a custom measureElement function that checks scroll direction
    2. Use cached measurements when scrolling up to prevent stuttering
    3. Only remeasure when scroll down or when direction is null

    Here's my solution:

      const virtualizer = useWindowVirtualizer({
        count: allPosts.length,
        estimateSize: getPostSize,
        overscan: 10,
        scrollMargin: parentOffsetRef.current,
        getItemKey: (index) => allPosts[index]?.id || `post-${index}`,
        measureElement: (element, _entry, instance) => {
          const direction = instance.scrollDirection;
          if (direction === 'forward' || direction === null) {
            // Allow remeasuring when scrolling down or direction is null
            return element.getBoundingClientRect().height;
          } else {
            // When scrolling up, use cached measurement to prevent stuttering
            const indexKey = Number(element.getAttribute('data-index'));
            const cachedMeasurement = instance.measurementsCache[indexKey]?.size;
            return cachedMeasurement || element.getBoundingClientRect().height;
          }
        },
      });
  21. suryaashish-tyke commented on Jun 2, 2025

    @suryaashish-tyke

    The solution is to implement a custom measureElement function that avoids remeasuring elements when scrolling upward, using cached measurements instead

    Needs

    1. Implement a custom measureElement function that checks scroll direction
    2. Use cached measurements when scrolling up to prevent stuttering
    3. Only remeasure when scroll down or when direction is null

    Here's my solution:

    const virtualizer = useWindowVirtualizer({
    count: allPosts.length,
    estimateSize: getPostSize,
    overscan: 10,
    scrollMargin: parentOffsetRef.current,
    getItemKey: (index) => allPosts[index]?.id || post-${index},
    measureElement: (element, _entry, instance) => {
    const direction = instance.scrollDirection;
    if (direction === 'forward' || direction === null) {
    // Allow remeasuring when scrolling down or direction is null
    return element.getBoundingClientRect().height;
    } else {
    // When scrolling up, use cached measurement to prevent stuttering
    const indexKey = Number(element.getAttribute('data-index'));
    const cachedMeasurement = instance.measurementsCache[indexKey]?.size;
    return cachedMeasurement || element.getBoundingClientRect().height;
    }
    },
    });

    thanks @hienhaobn, this worked for me.

  22. alexswan93 commented on Apr 3, 2026

    @alexswan93

    @heinhaobn by your efforts you have almost single-handedly brought world peace. I can now scroll in safety.

    Although maybe not strictly necessary, I also checked whether it is an Apple device before overriding this logic.

  23. zlanich commented on Apr 8, 2026

    @zlanich

    This also worked perfectly for me.

  24. LeandroDoudement commented on Apr 20, 2026

    @LeandroDoudement

    @nconfrey you're an angel! thank you for the solution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions