Frontend Development 24 min read

Implementing a Waterfall Layout with Virtual List in Vue3 and TypeScript

This article provides a step‑by‑step guide on building a responsive waterfall (masonry) layout combined with a virtual list in Vue 3 using TypeScript, covering data preparation, container sizing, position calculation, infinite scroll loading, handling variable card heights, and responsive column adjustments.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Implementing a Waterfall Layout with Virtual List in Vue3 and TypeScript

Implementing a Waterfall Layout with Virtual List in Vue3 and TypeScript

The author explores the waterfall (masonry) layout used on the Xiaohongshu homepage, focusing on the combination of a waterfall layout with a virtual list to efficiently render large numbers of image cards.

Implementation Idea

The layout arranges cards in columns of equal width; the first row is placed side‑by‑side, while subsequent rows place each card under the column with the smallest current height (greedy algorithm).

Data Preparation

Data is fetched from Xiaohongshu’s public API (or local JSON files) which provides image URLs and their original width/height. Because of anti‑hotlinking, the images are replaced with colored placeholders.

Pre‑loading Image Dimensions

function preLoadImage(link) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.src = link;
    img.onload = () => {
      // load event gives the real image size
      resolve({ width: img.width, height: img.height });
    };
    img.onerror = (err) => {
      reject(err);
    };
  });
}

Component Structure and Styles

The component consists of three main elements: container , list , and item . The container provides scrolling, the list is relatively positioned, and each item is absolutely positioned using transform: translate3d(...) .

<div class="fs-waterfall-container" ref="containerRef">
  <div class="fs-waterfall-list">
    <div class="fs-waterfall-item" v-for="(item, index) in state.cardList" :key="item.id"
         :style="{ width: `${state.cardPos[index].width}px`,
                    height: `${state.cardPos[index].height}px`,
                    transform: `translate3d(${state.cardPos[index].x}px, ${state.cardPos[index].y}px, 0)` }">
      <slot name="item" :item="item" :index="index"></slot>
    </div>
  </div>
</div>

Props and Initial State

Props include gap , column , bottom (distance to trigger load more), pageSize , and a request function that returns a Promise<ICardItem[]> . The reactive state tracks pagination, column heights, card positions, and a finish flag.

Initialization

On mount, the component calculates the card width based on container width, gap, and column count, then fetches the first page of data.

Calculating Minimum Column Height

const minColumn = computed(() => {
  let minIndex = -1,
      minHeight = Infinity;
  state.columnHeight.forEach((item, index) => {
    if (item < minHeight) {
      minHeight = item;
      minIndex = index;
    }
  });
  return { minIndex, minHeight };
});

Computing Card Positions

For each new card the component first computes the scaled image height, then determines whether it belongs to the first row or a later row, using the minimum‑column algorithm to set x and y and updating state.columnHeight .

const computedCardPos = (list) => {
  list.forEach((item, index) => {
    const cardHeight = Math.floor((item.height * state.cardWidth) / item.width);
    if (index < props.column) {
      state.cardPos.push({
        width: state.cardWidth,
        height: cardHeight,
        x: index ? index * (state.cardWidth + props.gap) : 0,
        y: 0,
      });
      state.columnHeight[index] = cardHeight + props.gap;
    } else {
      const { minIndex, minHeight } = minColumn.value;
      state.cardPos.push({
        width: state.cardWidth,
        height: cardHeight,
        x: minIndex ? minIndex * (state.cardWidth + props.gap) : 0,
        y: minHeight,
      });
      state.columnHeight[minIndex] += cardHeight + props.gap;
    }
  });
};

Infinite Scroll (Load More)

A scroll listener on the container checks the distance to the bottom; when it falls below the bottom prop, the next page is requested. A loading flag prevents duplicate requests.

const handleScroll = rafThrottle(() => {
  const { scrollTop, clientHeight, scrollHeight } = containerRef.value;
  const bottom = scrollHeight - clientHeight - scrollTop;
  if (bottom <= props.bottom) {
    !state.loading && getCardList(state.page, props.pageSize);
  }
});

Handling Variable Card Height (Xiaohongshu Cards)

Because Xiaohongshu cards contain variable‑height titles and author info, the component first calculates image height, then after the DOM is rendered (using nextTick ) measures the full card height via getBoundingClientRect() and recomputes positions.

Responsive Adjustments

A ResizeObserver watches the container size; on resize the component recalculates card width, clears previous positions, and recomputes the layout (debounced to avoid excessive reflows). Column count can also change based on breakpoints observed by the parent component, which updates the column prop; a watch on this prop triggers a layout reset.

Result

The final component renders a smooth, responsive waterfall layout with virtual scrolling, infinite loading, and proper handling of variable‑height cards, closely mimicking the Xiaohongshu homepage experience.

TypeScriptResponsive DesignInfinite ScrollVue3virtual-listWaterfall 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.