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.
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.
ByteFE
Cutting‑edge tech, article sharing, and practical insights from the ByteDance frontend team.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.