Skip to content

How Infinite Scrolling Works

· 9 min read

Most explanations of infinite scroll start at the wrong place. They explain how to fetch more data when the user gets near the edge of the list. That part is easy. The hard part is keeping the screen calm while rows appear, heights change, images load, and the user is trying to read.

I went through t3code because it is the clearest example I know. Once I read the code, the whole thing got simpler in my head. Infinite scroll is not one trick. It is a few small rules working together to protect the user’s place on the screen.

Start with the only three numbers that matter

The browser does not know what a “message” is. It only knows three numbers: how far you have scrolled, how tall the visible box is, and how tall the full content is.

In apps/web/src/chat-scroll.ts, the bottom check is just this:

export const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;

export function isScrollContainerNearBottom(
  position: ScrollPosition,
  thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
): boolean {
  const threshold = Number.isFinite(thresholdPx)
    ? Math.max(0, thresholdPx)
    : AUTO_SCROLL_BOTTOM_THRESHOLD_PX;

  const { scrollTop, clientHeight, scrollHeight } = position;
  if (![scrollTop, clientHeight, scrollHeight].every(Number.isFinite)) {
    return true;
  }

  const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
  return distanceFromBottom <= threshold;
}

That is the first principle to steal. Do not think in terms of “am I exactly at the bottom.” Think in terms of “am I close enough that the app still has permission to follow new content.”

That 64 pixel threshold matters more than it looks. Without it, the app keeps flipping between “follow” and “do not follow” because tiny layout changes move the list by a few pixels. With it, the UI feels forgiving.

Auto scroll should follow intent, not movement

Most bad chat UIs fail here. A new message arrives, the app forces the list to the bottom, and the user loses their place. That is not a scroll bug. That is a product bug.

In apps/web/src/components/ChatView.tsx, t3code keeps a tiny piece of state that answers one question: should the app still auto scroll right now.

const onMessagesScroll = useCallback(() => {
  const scrollContainer = messagesScrollRef.current;
  if (!scrollContainer) return;
  const currentScrollTop = scrollContainer.scrollTop;
  const isNearBottom = isScrollContainerNearBottom(scrollContainer);

  if (!shouldAutoScrollRef.current && isNearBottom) {
    shouldAutoScrollRef.current = true;
    pendingUserScrollUpIntentRef.current = false;
  } else if (shouldAutoScrollRef.current && pendingUserScrollUpIntentRef.current) {
    const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
    if (scrolledUp) {
      shouldAutoScrollRef.current = false;
    }
    pendingUserScrollUpIntentRef.current = false;
  } else if (shouldAutoScrollRef.current && !isNearBottom) {
    const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - 1;
    if (scrolledUp) {
      shouldAutoScrollRef.current = false;
    }
  }

  setShowScrollToBottom(!shouldAutoScrollRef.current);
  lastKnownScrollTopRef.current = currentScrollTop;
}, []);

The key idea is that auto scroll is not something you do. It is permission you keep until the user takes it away.

If the user scrolls up, the app stops following. If the user comes back near the bottom, the app starts following again. That one rule is why a chat can stream new output without feeling rude.

This generalizes cleanly beyond chat. Activity feeds, logs, dashboards, and notification panes should all work the same way. New data should not beat user intent.

Infinite does not mean render everything

The next thing people miss is that “infinite scroll” usually has nothing to do with infinity. It means the user should feel like they can keep going without the page breaking. To make that work, you usually cannot keep every row mounted forever.

In apps/web/src/components/chat/MessagesTimeline.tsx, t3code virtualizes the cold part of the timeline and keeps the newest part fully rendered:

const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8;

const firstUnvirtualizedRowIndex = useMemo(() => {
  const firstTailRowIndex = Math.max(rows.length - ALWAYS_UNVIRTUALIZED_TAIL_ROWS, 0);
  if (!activeTurnInProgress) return firstTailRowIndex;

  const turnStartedAtMs =
    typeof activeTurnStartedAt === "string" ? Date.parse(activeTurnStartedAt) : Number.NaN;
  let firstCurrentTurnRowIndex = -1;

  if (!Number.isNaN(turnStartedAtMs)) {
    firstCurrentTurnRowIndex = rows.findIndex((row) => {
      if (row.kind === "working") return true;
      if (!row.createdAt) return false;
      const rowCreatedAtMs = Date.parse(row.createdAt);
      return !Number.isNaN(rowCreatedAtMs) && rowCreatedAtMs >= turnStartedAtMs;
    });
  }

  if (firstCurrentTurnRowIndex < 0) return firstTailRowIndex;
  return Math.min(firstCurrentTurnRowIndex, firstTailRowIndex);
}, [activeTurnInProgress, activeTurnStartedAt, rows]);

const virtualizedRowCount = clamp(firstUnvirtualizedRowIndex, {
  minimum: 0,
  maximum: rows.length,
});

Then it hands only that older slice to the virtualizer:

const rowVirtualizer = useVirtualizer({
  count: virtualizedRowCount,
  getScrollElement: () => scrollContainer,
  getItemKey: (index: number) => rows[index]?.id ?? index,
  estimateSize: (index: number) => {
    const row = rows[index];
    if (!row) return 96;
    if (row.kind === "work") return 112;
    if (row.kind === "proposed-plan") return estimateTimelineProposedPlanHeight(row.proposedPlan);
    if (row.kind === "working") return 40;
    return estimateTimelineMessageHeight(row.message, { timelineWidthPx });
  },
  measureElement: measureVirtualElement,
  useAnimationFrameWithResizeObserver: true,
  overscan: 8,
});

This is a very good pattern. The rows near the bottom are changing all the time, so t3code leaves them real. The older rows are stable, so it virtualizes them.

That is the general lesson. Do not ask “should I virtualize this list.” Ask “which part of this list is hot, and which part is cold.” Keep the hot zone simple. Optimize the cold zone.

A virtualized list lives or dies on height estimates

A virtualized list has a problem. It needs to know how tall off screen rows are before it renders them. If it guesses badly, the scroll bar lies and the list jumps.

That is why t3code has a dedicated height estimator in apps/web/src/components/timelineHeight.ts:

export function estimateTimelineMessageHeight(
  message: TimelineMessageHeightInput,
  layout: TimelineHeightEstimateLayout = { timelineWidthPx: null },
): number {
  if (message.role === "assistant") {
    const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx);
    const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine);
    return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX;
  }

  if (message.role === "user") {
    const charsPerLine = estimateCharsPerLineForUser(layout.timelineWidthPx);
    const displayedUserMessage = deriveDisplayedUserMessageState(message.text);
    const renderedText =
      displayedUserMessage.contexts.length > 0
        ? [
            buildInlineTerminalContextText(displayedUserMessage.contexts),
            displayedUserMessage.visibleText,
          ]
            .filter((part) => part.length > 0)
            .join(" ")
        : displayedUserMessage.visibleText;
    const estimatedLines = estimateWrappedLineCount(renderedText, charsPerLine);
    const attachmentCount = message.attachments?.length ?? 0;
    const attachmentRows = Math.ceil(attachmentCount / ATTACHMENTS_PER_ROW);
    const attachmentHeight = attachmentRows * USER_ATTACHMENT_ROW_HEIGHT_PX;
    return USER_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX + attachmentHeight;
  }

  const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx);
  const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine);
  return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * LINE_HEIGHT_PX;
}

This is the part most “build an infinite list in 5 minutes” tutorials skip. Real rows are not all the same height. Messages wrap differently on mobile. User bubbles and assistant text use different widths. Images add rows of their own. If your estimate ignores that, the whole thing gets shaky.

The principle is simple. Predict the height from the same facts that control layout: width, text length, wrapping rules, and attachments. Then measure the real row after render and correct the guess.

When the guess changes, protect the visible content

Rows change height after the first guess. Images load. The window resizes. Fonts wrap differently. A user opens a collapsible panel. If you do nothing, the content in front of the user jumps.

t3code handles that inside the virtualizer setup:

rowVirtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
  const viewportHeight = instance.scrollRect?.height ?? 0;
  const scrollOffset = instance.scrollOffset ?? 0;
  const itemIntersectsViewport =
    item.end > scrollOffset && item.start < scrollOffset + viewportHeight;
  if (itemIntersectsViewport) {
    return false;
  }
  const remainingDistance = instance.getTotalSize() - (scrollOffset + viewportHeight);
  return remainingDistance > AUTO_SCROLL_BOTTOM_THRESHOLD_PX;
};

That reads like a lot of code, but the idea is straightforward. If a row outside the viewport changes size, the list is allowed to adjust the scroll position so the user keeps looking at the same content. If the changing row is already in view, the app leaves it alone because moving the whole viewport would feel worse.

This is the real heart of infinite scroll. The user does not care whether you loaded page 12. The user cares whether the text they were reading stayed under their eyes.

When the user opens something, anchor to what they touched

There is one more trick in ChatView.tsx that I wish more apps used. When a click causes the layout to expand or collapse, t3code remembers the clicked element’s position, lets the DOM update, then adjusts scrollTop by the difference:

pendingInteractionAnchorRef.current = {
  element: trigger,
  top: trigger.getBoundingClientRect().top,
};

pendingInteractionAnchorFrameRef.current = window.requestAnimationFrame(() => {
  const anchor = pendingInteractionAnchorRef.current;
  pendingInteractionAnchorRef.current = null;
  const activeScrollContainer = messagesScrollRef.current;
  if (!anchor || !activeScrollContainer) return;
  if (!anchor.element.isConnected || !activeScrollContainer.contains(anchor.element)) return;

  const nextTop = anchor.element.getBoundingClientRect().top;
  const delta = nextTop - anchor.top;
  if (Math.abs(delta) < 0.5) return;

  activeScrollContainer.scrollTop += delta;
  lastKnownScrollTopRef.current = activeScrollContainer.scrollTop;
});

That is such a good general rule. If the user taps a thing and the page changes shape, anchor the viewport to that thing. The user should feel like the content opened in place, not like the page slipped under their finger.

Test the illusion in a browser

The best part of t3code is that it does not stop at helper tests. It tests the real layout in a browser.

In apps/web/src/components/ChatView.browser.tsx, it renders the chat, measures a real row, and checks that the estimate stays close across different viewport sizes:

it.each(TEXT_VIEWPORT_MATRIX)(
  "keeps long user message estimate close at the $name viewport",
  async (viewport) => {
    const userText = "x".repeat(3_200);
    const mounted = await mountChatView({
      viewport,
      snapshot: createSnapshotForTargetUser({
        targetMessageId,
        targetText: userText,
      }),
    });

    const { measuredRowHeightPx, timelineWidthMeasuredPx, renderedInVirtualizedRegion } =
      await mounted.measureUserRow(targetMessageId);

    expect(renderedInVirtualizedRegion).toBe(true);

    const estimatedHeightPx = estimateTimelineMessageHeight(
      { role: "user", text: userText, attachments: [] },
      { timelineWidthPx: timelineWidthMeasuredPx },
    );

    expect(Math.abs(measuredRowHeightPx - estimatedHeightPx)).toBeLessThanOrEqual(
      viewport.textTolerancePx,
    );
  },
);

That is exactly the right testing mindset. Infinite scroll bugs are usually not pure logic bugs. They are layout bugs, resize bugs, and “this felt stable on desktop but not on mobile” bugs. The only honest way to test that is to render the real UI and measure it.

Once I saw the t3code version, infinite scroll stopped feeling mysterious. You decide whether the user is near the edge. You treat auto scroll as permission, not force. You virtualize the cold part of the list. You guess heights before render, then correct them carefully. You anchor interactions so the content the user touched stays put. That is the whole game, and it applies just as well to chats, feeds, logs, and dashboards as it does to this repo.