Skip to content

Responsive grid with dynamic card sizes

Example code

Styling

body {
  --darker-bg-color: rgb(71, 81, 91);
  --lighter-bg-color: lightslategray;
  background-color: var(--lighter-bg-color);
}

.lv-scroller * {
  box-sizing: border-box;
  padding: 0;
  margin: 0;
  color: white;
}

.lv-scroller {
  width: 340px;
  height: 460px;
  padding: 16px 16px 40px 16px;
  background-color: var(--darker-bg-color);
  resize: both;
}

.lv-viewport {
  container-type: size;
  width: 100%;
  height: 100%;
  border: 1px solid var(--lighter-bg-color);
  border-radius: 8px;
  background-color: var(--lighter-bg-color);
  box-shadow: inset 0px 0px 18px 2px var(--darker-bg-color);
}

.lv-content-layer {
  --min-col-size: 220px;
  gap: 1cqh 1cqw;
  justify-content: center;
  grid-template-columns: repeat(
    auto-fit,
    minmax(min(var(--min-col-size), 100%), 1fr)
  );
}

.article-card {
  position: relative;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  border-radius: 8px;
  background-color: var(--darker-bg-color);
}

.ac-index {
  position: absolute;
  top: 8px;
  right: 8px;
  padding: 2px 6px;
  border-radius: 4px;
  background-color: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(4px);
  font-size: 10px;
  line-height: 1.4;
  letter-spacing: 0.02em;
}

.ac-image {
  display: block;
  width: 100%;
  aspect-ratio: 5 / 3;
  object-fit: cover;
}

.ac-body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px 14px;
}

.ac-title {
  font-size: clamp(14px, 2.6cqw, 20px);
  font-weight: 600;
  line-height: 1.3;
  max-width: 85%;
}

.ac-excerpt {
  font-size: clamp(12px, 2cqw, 15px);
  line-height: 1.5;
  opacity: 0.8;
}

.ac-button {
  align-self: flex-start;
  margin-top: 4px;
  padding: 6px 14px;
  border: 1px solid currentColor;
  border-radius: 999px;
  background: none;
  color: inherit;
  font-size: 13px;
  cursor: pointer;
}

.ac-button:hover {
  background-color: var(--lighter-bg-color);
}

Vanilla

import LayoutVirtual, { type ListItemProps, type VirtualizedListDOMClasses } from 'layout-virtual';

const styling: VirtualizedListDOMClasses = {
  scrollerClass: 'lv-scroller',
  viewportClass: 'lv-viewport',
  contentLayerClass: 'lv-content-layer',
};

type Data = { i: number; image?: string; title: string; excerpt: string };

const titles = [
  'Windowing 101',
  'Recycling DOM nodes without losing scroll position',
  'Dynamic item heights',
  'Why measure when the browser already knows? What is measuring and how and why it works?',
  'Smooth scrolling through ten thousand rows',
  'Overscan: rendering a little more than you see',
  'From fixed-size lists to fully dynamic grids',
];

const excerpts = [
  'Render only what fits.',
  'Recycling pools reuse existing nodes instead of mounting and unmounting on every scroll event.',
  'Dynamic heights mean no upfront measurement pass and no layout thrashing as items resize.',
  'A short primer on how virtualization keeps memory and paint cost flat regardless of list length.',
  'Scrolling through a long feed should feel the same whether it has a hundred items or a hundred thousand, and that consistency comes from windowing the visible range and letting everything else stay unmounted until it is needed, then recycling the freed nodes for whatever scrolls into view next.',
];

function ArticleCard({ data, index }: ListItemProps<Data>) {
  const articleCard = document.createElement('div');
  const itemIndex = document.createElement('div');
  const body = document.createElement('div');
  const title = document.createElement('h3');
  const excerpt = document.createElement('p');
  const button = document.createElement('button');

  articleCard.classList.add('article-card');
  articleCard.dataset.index = index.toString();

  itemIndex.classList.add('ac-index');
  itemIndex.textContent = `#${data.i}`;
  articleCard.append(itemIndex);

  if (data.image) {
    const image = document.createElement('img');
    image.classList.add('ac-image');
    image.src = data.image;
    image.alt = '';
    image.loading = 'lazy';
    articleCard.append(image);
  }

  body.classList.add('ac-body');

  title.classList.add('ac-title');
  title.textContent = data.title;

  excerpt.classList.add('ac-excerpt');
  excerpt.textContent = data.excerpt;

  button.classList.add('ac-button');
  button.textContent = 'Learn more';

  body.append(title, excerpt, button);
  articleCard.append(body);

  return articleCard;
}

const data = Array.from({ length: 1000 }, (_, i): Data => ({
  i,
  image: i % 3 ? undefined : `https://picsum.photos/seed/${i}/400/240`,
  title: titles[i % titles.length]!,
  excerpt: excerpts[i % excerpts.length]!,
}));

function updateStats(startIndex: number, endIndex: number) {
  const total = endIndex - startIndex + 1;
  stats.textContent = `Rendered indices ${startIndex} - ${endIndex}, total ${total} of ${data.length}.`;
}

const title = document.createElement('h4');
title.textContent = 'Try resizing the container and scroll.';

const { container } = LayoutVirtual({ 
  overscanHeight: 200, data, renderItem: ArticleCard, ...styling, onAfterItemsRendered: updateStats,
});

const stats = document.createElement('div');
const app = document.getElementById('app')!;
app.append(title, stats, container);

React

import { useCallback, useState, useMemo } from 'react';
import LayoutVirtual, { type VirtualizedListReactClasses, type ListItemProps } from 'react-layout-virtual';

const styling: VirtualizedListReactClasses = {
  scrollerClass: 'lv-scroller',
  viewportClass: 'lv-viewport',
  contentLayerClass: 'lv-content-layer',
};

type Data = { i: number; image?: string; title: string; excerpt: string };

const titles = [
  'Windowing 101',
  'Recycling DOM nodes without losing scroll position',
  'Dynamic item heights',
  'Why measure when the browser already knows? What is measuring and how and why it works?',
  'Smooth scrolling through ten thousand rows',
  'Overscan: rendering a little more than you see',
  'From fixed-size lists to fully dynamic grids',
];

const excerpts = [
  'Render only what fits.',
  'Recycling pools reuse existing nodes instead of mounting and unmounting on every scroll event.',
  'Dynamic heights mean no upfront measurement pass and no layout thrashing as items resize.',
  'A short primer on how virtualization keeps memory and paint cost flat regardless of list length.',
  'Scrolling through a long feed should feel the same whether it has a hundred items or a hundred thousand, and that consistency comes from windowing the visible range and letting everything else stay unmounted until it is needed, then recycling the freed nodes for whatever scrolls into view next.',
];

function ArticleCard({ data, index }: ListItemProps<Data>) {
  const { image, title, excerpt } = data;

  return (
    <div className={'article-card'} data-index={index}>
      <div className={'ac-index'}>{`#${data.i}`}</div>
      {image && <img className={'ac-image'} src={image} alt={''} loading={'lazy'} />}
      <div className={'ac-body'}>
        <h3 className={'ac-title'}>{title}</h3>
        <p className={'ac-excerpt'}>{excerpt}</p>
        <button className={'ac-button'}>Learn more</button>
      </div>
    </div>
  );
}

const ResponsiveGridExample = () => {
  const data = useMemo(() => Array.from({ length: 1000 }, (_, i) => ({
    i,
    image: i % 3 ? undefined : `https://picsum.photos/seed/${i}/400/240`,
    title: titles[i % titles.length]!,
    excerpt: excerpts[i % excerpts.length]!,
  })), []);
  const [renderedIndices, setRenderedIndices] = useState({ startIndex: 0, endIndex: 0 });
  const { startIndex, endIndex } = renderedIndices;
  const total = endIndex - startIndex + 1;

  const updateStats = useCallback((startIndex: number, endIndex: number) => {
    setRenderedIndices({ startIndex, endIndex });
  }, []);

  return (
    <>
      <h4>Try resizing the container and scroll.</h4>
      <div>Rendered indices {startIndex} - {endIndex}, total {total} of {data.length}.</div>
      <LayoutVirtual<Data> overscanHeight={200} data={data} renderItem={ArticleCard} {...styling} onAfterItemsRendered={updateStats} />
    </>
  );
};

export default ResponsiveGridExample;

Vue

<script setup lang="ts">
import { ref, computed } from 'vue';
import LayoutVirtual from 'vue-layout-virtual';

const styling = {
  scrollerClass: 'lv-scroller',
  viewportClass: 'lv-viewport',
  contentLayerClass: 'lv-content-layer',
};

type Data = { i: number; image?: string; title: string; excerpt: string };

const titles = [
  'Windowing 101',
  'Recycling DOM nodes without losing scroll position',
  'Dynamic item heights',
  'Why measure when the browser already knows? What is measuring and how and why it works?',
  'Smooth scrolling through ten thousand rows',
  'Overscan: rendering a little more than you see',
  'From fixed-size lists to fully dynamic grids',
];

const excerpts = [
  'Render only what fits.',
  'Recycling pools reuse existing nodes instead of mounting and unmounting on every scroll event.',
  'Dynamic heights mean no upfront measurement pass and no layout thrashing as items resize.',
  'A short primer on how virtualization keeps memory and paint cost flat regardless of list length.',
  'Scrolling through a long feed should feel the same whether it has a hundred items or a hundred thousand, and that consistency comes from windowing the visible range and letting everything else stay unmounted until it is needed, then recycling the freed nodes for whatever scrolls into view next.',
];

const data = Array.from({ length: 1000 }, (_, i): Data => ({
  i,
  image: i % 3 ? undefined : `https://picsum.photos/seed/${i}/400/240`,
  title: titles[i % titles.length]!,
  excerpt: excerpts[i % excerpts.length]!,
}));
const startIndex = ref(0);
const endIndex = ref(0);
const total = computed(() => endIndex.value - startIndex.value + 1);

const updateStats = (start: number, end: number) => {
  startIndex.value = start;
  endIndex.value = end;
};
</script>

<template>
  <h4>Try resizing the container and scroll.</h4>
  <div>Rendered indices {{ startIndex }} - {{ endIndex }}, total {{ total }} of {{ data.length }}.</div>
  <layout-virtual :overscanHeight="200" :data="data" v-bind="styling" @after-items-rendered="updateStats">
    <template #renderItem="{ data: itemData, index }">
      <div class="article-card" :data-index="index">
        <div class="ac-index">{{ `#${itemData.i}` }}</div>
        <img v-if="itemData.image" class="ac-image" :src="itemData.image" alt="" loading="lazy">
        <div class="ac-body">
          <h3 class="ac-title">{{ itemData.title }}</h3>
          <p class="ac-excerpt">{{ itemData.excerpt }}</p>
          <button class="ac-button">Learn more</button>
        </div>
      </div>
    </template>
  </layout-virtual>
</template>

Angular

import '@angular/compiler';
import { CommonModule } from '@angular/common';
import { Component } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import LayoutVirtual from 'angular-layout-virtual';

type Data = { i: number; image?: string; title: string; excerpt: string };

const titles = [
  'Windowing 101',
  'Recycling DOM nodes without losing scroll position',
  'Dynamic item heights',
  'Why measure when the browser already knows? What is measuring and how and why it works?',
  'Smooth scrolling through ten thousand rows',
  'Overscan: rendering a little more than you see',
  'From fixed-size lists to fully dynamic grids',
];

const excerpts = [
  'Render only what fits.',
  'Recycling pools reuse existing nodes instead of mounting and unmounting on every scroll event.',
  'Dynamic heights mean no upfront measurement pass and no layout thrashing as items resize.',
  'A short primer on how virtualization keeps memory and paint cost flat regardless of list length.',
  'Scrolling through a long feed should feel the same whether it has a hundred items or a hundred thousand, and that consistency comes from windowing the visible range and letting everything else stay unmounted until it is needed, then recycling the freed nodes for whatever scrolls into view next.',
];

@Component({
  selector: '#app',
  standalone: true,
  imports: [CommonModule, LayoutVirtual],
  template: `
    <h4>Try resizing the container and scroll.</h4>
    <div>Rendered indices {{ startIndex }} - {{ endIndex }}, total {{ total }} of {{ data.length }}.</div>
    <layout-virtual 
      [overscanHeight]="200" 
      [data]="data" 
      scrollerClass="lv-scroller"
      viewportClass="lv-viewport"
      contentLayerClass="lv-content-layer"
      (afterItemsRendered)="updateStats(...$event)">
        <ng-template #renderItem let-data="data" let-index="index">
          <div class="article-card" [attr.data-index]="index">
            <div class="ac-index">#{{ data.i }}</div>
            @if (data.image) {
              <img class="ac-image" [src]="data.image" alt="" loading="lazy">
            }
            <div class="ac-body">
              <h3 class="ac-title">{{ data.title }}</h3>
              <p class="ac-excerpt">{{ data.excerpt }}</p>
              <button class="ac-button">Learn more</button>
            </div>
          </div>
        </ng-template>
    </layout-virtual>
  `,
})
export default class ResponsiveGridExample {
  data = Array.from({ length: 1000 }, (_, i): Data => ({
    i,
    image: i % 3 ? undefined : `https://picsum.photos/seed/${i}/400/240`,
    title: titles[i % titles.length]!,
    excerpt: excerpts[i % excerpts.length]!,
  }));
  startIndex = 0;
  endIndex = 0;

  get total() {
    return this.endIndex - this.startIndex + 1;
  }

  updateStats = (startIndex: number, endIndex: number, _: unknown) => {
    this.startIndex = startIndex;
    this.endIndex = endIndex;
  };
}

bootstrapApplication(ResponsiveGridExample).catch((error: unknown) => {
  console.error(error);
});

Try resizing the scroller: the same items adapt to the available width, collapsing into a single-column list when the container becomes too narrow and expanding back into a grid when there’s room again.

As the column width changes, text wraps across different numbers of lines, so each card’s height is dynamic and depends on its actual content. No separate list/grid component, no item wrapper, manual measurement step, or estimated item sizes are required. Only data, a render function/template, and ordinary CSS classes need to be provided.

The items are laid out and styled as if they were inside a regular container. The library tracks the precise scroll position regardless of item sizes, gaps, margins, and layout changes.