Frontend Development 13 min read

Responsive Waterfall Flow Layout Implementation in React

This article explains how to build a responsive waterfall (masonry) layout in React, covering the underlying principle of absolute positioning, dynamic column calculation, item placement based on column heights, lazy image loading, and provides the complete TypeScript component and CSS code.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Responsive Waterfall Flow Layout Implementation in React

The article introduces a responsive waterfall (masonry) layout, a pattern frequently used on both PC and mobile web pages, and demonstrates how to recreate the effect seen in the Xiaohongshu app.

It first shows the visual result of the layout, then explains the core principle: each item is absolutely positioned with left and top initially set to 0, and the final transform values are computed based on container width, item height, and column heights.

Implementation steps are described from easy to hard:

1. Initialize data – a placeholder list is created, then real image data (three sample images) is loaded asynchronously.

2. Determine column count and column width – the container width is observed with a ResizeObserver , and the number of columns is chosen (6, 4, or 2) according to break‑points (≥1200 px, 768‑1199 px, <1200 px). The column width is calculated by subtracting gaps.

3. Calculate each item’s position – for the first row items are placed left‑to‑right; for subsequent rows the algorithm finds the column with the smallest current height and places the next item underneath it, updating left , top and height for each item.

4. Update item height – images are lazy‑loaded; once an image loads its natural dimensions are used to compute the displayed height (including a fixed 40 px for the title). The parent component receives the new height via a callback and recomputes the layout.

The article also outlines two auxiliary tasks: recording the scroll position to trigger lazy loading, and loading more data when the user scrolls near the bottom.

Full component code (React + TypeScript):

import React, { useState, useEffect, useRef, useMemo, useCallback, Fragment } from 'react';
import img1 from '../assets/imgs/1.jpg';
import img2 from '../assets/imgs/2.jpg';
import img12 from '../assets/imgs/12.png';
import './WaterfallFlow.scss';

interface WaterfallFlowItemProps {
  showBorder: number;
  src: string;
  title: string;
  style: React.CSSProperties;
  unitWidth: number;
  index: number;
  sizeChange?: (height: number, index: number) => void;
}

const WaterfallFlowItem = (props: WaterfallFlowItemProps) => {
  const { src, title, style = {}, sizeChange = () => {}, unitWidth, index, showBorder } = props;
  const frameDom = useRef(null);
  const [isLoading, setIsLoading] = useState(false);
  const [imgInfo, setImgInfo] = useState({ height: 1, width: 1 });
  const imgDom = useRef
(null);

  const top = useMemo(() => {
    const t = style.transform ? Number(style.transform.substring(style.transform.indexOf(',') + 1, style.transform.length - 3)) : undefined;
    return t;
  }, [style]);

  const isImgShow = useMemo(() => {
    if (top === undefined) return false;
    return top <= showBorder;
  }, [top, showBorder]);

  useEffect(() => {
    if (!imgDom.current || src === '' || !isImgShow) return;
    const img = new Image();
    img.src = src;
    img.onload = () => {
      setImgInfo({ height: img.height, width: img.width });
      setIsLoading(true);
    };
    imgDom.current.src = src;
  }, [src, isImgShow]);

  useEffect(() => {
    const height = imgInfo.height * (unitWidth / imgInfo.width);
    if (isLoading) {
      sizeChange(height + 40, index);
    }
  }, [imgInfo, index, unitWidth, isLoading, sizeChange]);

  return (
{title && title}
        {!isLoading &&
}
);
};

export default function WaterfallFlow() {
  const scrollParent = useRef(null);
  const [scrollTop, setScrollTop] = useState(0);
  const [list, setList] = useState
([]);
  const waterfallFlowDom = useRef(null);
  const [styleList, setStyleList] = useState
([]);
  const heightList = [170, 230, 300];
  const isLoadingData = useRef(false);

  const createRandomNum = useCallback((min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min, []);

  const waterfallFlowListInfo = useRef<{ left: number; top: number; height: number }[]>([]);
  const [frameInfo, setFrameInfoInfo] = useState({ width: 0 });

  const rowsNum = useMemo(() => {
    const w = frameInfo.width || 0;
    if (w >= 1200) return 6;
    if (w >= 768 && w <= 1199) return 4;
    return 2;
  }, [frameInfo]);

  const unitWidth = useMemo(() => (frameInfo.width - (rowsNum - 1) * 10) / rowsNum, [rowsNum, frameInfo]);

  const getStyleList = useCallback(() => {
    const temporaryStyleList: React.CSSProperties[] = styleList;
    const bottomItemIndex: number[] = [];
    for (let i = 0; i < list.length; i++) {
      const currentRow = Math.floor(i / rowsNum);
      const remainder = (i % rowsNum) + 1;
      let minHeightInd = 0;
      let minHeight = 9999999999;
      if (currentRow === 0) {
        bottomItemIndex[i] = i;
      } else {
        for (let j = 0; j < bottomItemIndex.length; j++) {
          const h = waterfallFlowListInfo.current[bottomItemIndex[j]].top + waterfallFlowListInfo.current[bottomItemIndex[j]].height;
          if (h < minHeight) {
            minHeightInd = j;
            minHeight = h;
          }
        }
        bottomItemIndex[minHeightInd] = i;
      }
      if (!waterfallFlowListInfo.current[i]) waterfallFlowListInfo.current[i] = {} as any;
      if (currentRow === 0) {
        waterfallFlowListInfo.current[i].left = remainder === 1 ? 0 : waterfallFlowListInfo.current[i - 1].left + unitWidth + 10;
        waterfallFlowListInfo.current[i].top = 0;
      } else {
        waterfallFlowListInfo.current[i].left = waterfallFlowListInfo.current[minHeightInd].left;
        waterfallFlowListInfo.current[i].top = minHeight + 25;
      }
      waterfallFlowListInfo.current[i].height = waterfallFlowListInfo.current[i].height || heightList[createRandomNum(0, 2)];
      temporaryStyleList[i] = {
        transform: `translate(${waterfallFlowListInfo.current[i].left}px,${waterfallFlowListInfo.current[i].top}px)`,
        width: `${unitWidth}px`,
        height: waterfallFlowListInfo.current[i].height,
      };
    }
    return [...temporaryStyleList];
  }, [unitWidth, rowsNum, list]);

  const onSizeChange = useCallback((height: number, index: number) => {
    if (!waterfallFlowListInfo.current[index]) waterfallFlowListInfo.current[index] = {} as any;
    waterfallFlowListInfo.current[index].height = height;
    setStyleList(getStyleList());
  }, [getStyleList]);

  useEffect(() => {
    setStyleList(getStyleList());
  }, [unitWidth, rowsNum, list]);

  useEffect(() => {
    isLoadingData.current = true;
    const placeholder = Array.from({ length: 50 }, () => ({ src: "", title: "" }));
    setList(placeholder);
    const realData: any[] = [];
    for (let i = 0; i < 50; i++) {
      const item = i % 3 === 0 ? { src: img1, title: `第${i}个Item` } : i % 3 === 1 ? { src: img2, title: `第${i}个Item` } : { src: img12, title: `第${i}个Item` };
      realData.push(item);
    }
    setTimeout(() => {
      setList(realData);
      isLoadingData.current = false;
    }, 1200);
  }, []);

  const onResize = useCallback(() => {
    if (!waterfallFlowDom.current) return;
    setFrameInfoInfo({ width: (waterfallFlowDom.current as HTMLDivElement).getBoundingClientRect().width });
  }, []);

  useEffect(() => {
    if (!waterfallFlowDom.current) return;
    const ro = new ResizeObserver(() => onResize());
    ro.observe(waterfallFlowDom.current);
    return () => ro.disconnect();
  }, []);

  const onScroll = useCallback(() => {
    setScrollTop((scrollParent.current as any).scrollTop);
    const top = (scrollParent.current as any).scrollTop;
    const clientHeight = (scrollParent.current as any).clientHeight;
    const scrollHeight = (scrollParent.current as any).scrollHeight;
    if (scrollHeight - clientHeight / 3 <= top + clientHeight && !isLoadingData.current) {
      isLoadingData.current = true;
      const more: any[] = [];
      for (let i = 0; i < 50; i++) {
        const item = i % 3 === 0 ? { src: img1, title: `第${i}个Item` } : i % 3 === 1 ? { src: img2, title: `第${i}个Item` } : { src: img12, title: `第${i}个Item` };
        more.push(item);
      }
      setTimeout(() => {
        isLoadingData.current = false;
        setList(prev => [...prev, ...more]);
      }, 1200);
    }
  }, []);

  useEffect(() => {
    (scrollParent.current as any).addEventListener('scroll', onScroll);
    return () => (scrollParent.current as any).removeEventListener('scroll', onScroll);
  }, []);

  return (
响应式瀑布流
{list.map((item, ind) => (
))}
);
}

The accompanying SCSS defines the container, title, content area, and the absolute‑positioned item styles, including rounded corners, shadows, and responsive image handling.

.waterfallFlow {
  flex: 1;
  height: 100vh;
  overflow-y: auto;
  &__title { text-align: center; }
  &__content { position: relative; box-sizing: border-box; background-color: red; }
}

.WaterfallItem {
  display: flex;
  flex-direction: column;
  position: absolute;
  top: 0;
  left: 0;
  border-radius: 20px;
  background-color: #FFF;
  box-sizing: border-box;
  box-shadow: 0 0 12px 2px rgba(0,0,0,0.1);
  &__img { flex: 1; overflow: hidden; border-radius: 20px; background: #f7f7f7; }
  img { display: inline-block; height: 100%; width: 100%; object-fit: cover; border-radius: 20px; }
  &__name { height: 30px; margin-top: 10px; padding: 0px 5px; box-sizing: border-box; }
  &__name--placeholder { height: 20px; background: #fbfbfb; }
}

In conclusion, readers are encouraged to try the component themselves and to follow the author for more similar tutorials.

frontendJavaScriptreactCSSresponsive designWaterfall Layout
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

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.