Frontend Development 14 min read

Implementing a Virtual Scrolling List in Ant Design Modal for Performance Optimization

This article explains how to replace a sluggish, long list inside an Ant Design Modal with a custom virtual scrolling component, covering fixed‑height and variable‑height implementations, buffer optimization, binary search for index lookup, and includes full React code examples.

ByteFE
ByteFE
ByteFE
Implementing a Virtual Scrolling List in Ant Design Modal for Performance Optimization

Before Refactor

Opening the edit Modal caused noticeable lag and delayed response when closing, due to rendering a very long list inside the Modal.

After Refactor

After implementing a virtual list, the Modal opens and closes instantly, providing a smooth user experience.

Basic Knowledge

A virtual list renders only the items that are visible in the viewport plus a small buffer, instead of the entire dataset, dramatically reducing DOM nodes and improving performance.

...
....

Fixed‑Height Virtual List (0x1)

Key variables:

startIndex : index of the first visible item.

endIndex : index of the last visible item.

startOffset(scrollTop) : number of items hidden above the viewport.

The container vListContainer has overflow-y: auto . The phantomContent element holds a tall placeholder with position: absolute for each item, while the actual visible items are rendered on top.

onScroll(evt: any) {
  if (evt.target === this.scrollingContainer.current) {
    const { scrollTop } = evt.target;
    const { startIndex, total, rowHeight, limit } = this;
    const currentStartIndex = Math.floor(scrollTop / rowHeight);
    if (currentStartIndex !== startIndex) {
      this.startIndex = currentStartIndex;
      this.endIndex = Math.min(currentStartIndex + limit, total - 1);
      this.setState({ scrollTop });
    }
  }
}
renderDisplayContent = () => {
  const { rowHeight, startIndex, endIndex } = this;
  const content = [];
  for (let i = startIndex; i <= endIndex; ++i) {
    content.push(
      rowRenderer({
        index: i,
        style: {
          width: '100%',
          height: rowHeight + 'px',
          position: "absolute",
          left: 0,
          right: 0,
          top: i * rowHeight,
          borderBottom: "1px solid #000",
        }
      })
    );
  }
  return content;
};

Optimization with Buffer

To avoid flickering during fast scrolling, a buffer of extra items is rendered above and below the viewport.

onScroll(evt: any) {
  ...
  const currentStartIndex = Math.floor(scrollTop / rowHeight);
  if (currentStartIndex !== originStartIdx) {
    this.originStartIdx = currentStartIndex;
    this.startIndex = Math.max(this.originStartIdx - bufferSize, 0);
    this.endIndex = Math.min(this.originStartIdx + this.limit + bufferSize, total - 1);
    this.setState({ scrollTop });
  }
}

Variable‑Height List (0x2)

When item heights are not fixed, three strategies exist: provide exact heights, double‑render off‑screen for measurement, or use an estimated height that is corrected after rendering.

The implementation introduces an actualContent container with position: absolute and moves it via a CSS transform based on scroll offset.

getTransform() {
  const { scrollTop } = this.state;
  const { rowHeight, bufferSize, originStartIdx } = this;
  return `translate3d(0,${
    scrollTop -
    (scrollTop % rowHeight) -
    Math.min(originStartIdx, bufferSize) * rowHeight
  }px,0)`;
}

Heights are cached in an array of objects ( CachedPosition ) and updated after each render.

interface CachedPosition {
  index: number;
  top: number;
  bottom: number;
  height: number;
  dValue: number;
}
cachedPositions: CachedPosition[] = [];

initCachedPositions = () => {
  const { estimatedRowHeight } = this;
  this.cachedPositions = [];
  for (let i = 0; i < this.total; ++i) {
    this.cachedPositions[i] = {
      index: i,
      height: estimatedRowHeight,
      top: i * estimatedRowHeight,
      bottom: (i + 1) * estimatedRowHeight,
      dValue: 0,
    };
  }
};

After rendering, actual heights are measured and the cache is updated, adjusting the phantom container’s total height.

componentDidUpdate() {
  if (this.actualContentRef.current && this.total > 0) {
    this.updateCachedPositions();
  }
}

updateCachedPositions = () => {
  const nodes = this.actualContentRef.current.childNodes;
  const start = nodes[0];
  nodes.forEach((node: HTMLDivElement) => {
    if (!node) return;
    const rect = node.getBoundingClientRect();
    const { height } = rect;
    const index = Number(node.id.split('-')[1]);
    const oldHeight = this.cachedPositions[index].height;
    const dValue = oldHeight - height;
    if (dValue) {
      this.cachedPositions[index].bottom -= dValue;
      this.cachedPositions[index].height = height;
      this.cachedPositions[index].dValue = dValue;
    }
  });
  // recompute positions and phantom height ...
};

Start index is now found via binary search on the cached positions.

getStartIndex = (scrollTop = 0) => {
  let idx = binarySearch
(
    this.cachedPositions,
    scrollTop,
    (currentValue, targetValue) => {
      const currentCompareValue = currentValue.bottom;
      if (currentCompareValue === targetValue) return CompareResult.eq;
      if (currentCompareValue < targetValue) return CompareResult.lt;
      return CompareResult.gt;
    }
  );
  const targetItem = this.cachedPositions[idx];
  if (targetItem.bottom < scrollTop) idx += 1;
  return idx;
};
export enum CompareResult {
  eq = 1,
  lt,
  gt,
}

export function binarySearch
(list: T[], value: VT, compareFunc: (current: T, value: VT) => CompareResult) {
  let start = 0;
  let end = list.length - 1;
  let tempIndex = null;
  while (start <= end) {
    tempIndex = Math.floor((start + end) / 2);
    const midValue = list[tempIndex];
    const compareRes = compareFunc(midValue, value);
    if (compareRes === CompareResult.eq) return tempIndex;
    if (compareRes === CompareResult.lt) start = tempIndex + 1;
    else end = tempIndex - 1;
  }
  return tempIndex;
}

Finally, the transform for the actualContent container is calculated based on the updated start index.

getTransform = () => `translate3d(0,${this.startIndex >= 1 ? this.cachedPositions[this.startIndex - 1].bottom : 0}px,0)`;

Live demos are provided for each stage of the implementation.

frontendperformanceoptimizationreactVirtual Scrollingantdlist rendering
ByteFE
Written by

ByteFE

Cutting‑edge tech, article sharing, and practical insights from the ByteDance frontend team.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.