Horizontal Dynamic

Horizontal scrolling with variable item widths
Horizontal scrolling with 1,000 items with different widths measured via ResizeObserver. Even items are 150px, odd items are 300px. Try resizing the container!

How to build a feature like this

To virtualize a horizontal list whose items differ in width, the engine cannot place items arithmetically the way it does with uniform sizes - each item's offset depends on the widths of everything before it. Dynamic mode solves this by measuring each rendered cell with a ResizeObserver so totals follow the measured sizes. The cost is real but bounded: layout starts from an estimate that is corrected as cells mount, and totals only become exact once the relevant range has been measured. This page shows how to choose that mode deliberately rather than by accident.

1. Choose the size strategy that matches your data

item-size accepts four forms, each trading speed against flexibility. A positive number means uniform sizes and pure O(1) arithmetic - the fastest, but only valid when every item really is that size. An array describes a repeating width pattern (e.g. [150, 300]), and a function (item, index) => number expresses a width known up front that varies per item; both let the engine lay out far-off items from the declared pattern or function without mounting them - avoiding dynamic measurement, at the cost of per-item storage rather than a uniform number's O(1). Pass 0, null, or undefined - or omit the prop entirely - to switch to dynamic mode, where sizes are measured from the DOM. Use dynamic only when widths are genuinely content-driven and unknowable until rendered (wrapping text, media, responsive cells); if you can compute them, an array or function skips the measuring cost.

2. Give every cell a definite, stable width

A measured cell must render at the width you intend and not change after it mounts. Drive the width from your data and apply it as an explicit inline size on the cell (the snippet uses inlineSize from a per-item width), and reserve space for late media content so a post-mount resize does not shift neighbors. Because each rendered box is observed, a live resize (window resize, data-driven width change) is caught and the axis is re-laid automatically.

The examples also draw the built-in virtual scrollbar (boolean virtual-scrollbar) on the list. Besides consistent cross-browser styling it is a performance improvement: the overlay bar is driven by the engine's own scroll math, so its rendering cost stays flat no matter how long the list grows.

<script setup lang="ts">
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
import '@pdanpdan/virtual-scroll/style.css';

// Each item knows its own width; no `item-size` prop is passed, so the engine
// treats the axis as dynamic and measures the rendered boxes.
const items = Array.from({ length: 1000 }, (_, i) => ({
  id: i,
  width: i % 2 === 0 ? 150 : 300,
}));
</script>

<template>
  <VirtualScroll
    virtual-scrollbar
    class="h-dyn"
    direction="horizontal"
    :items="items"
    :default-item-size="220"
    aria-label="Dynamic width list"
  >
    <template #item="{ item }">
      <!-- Omit item-size → ResizeObserver reports this box's inline size. -->
      <div class="card" :style="{ inlineSize: `${ item.width }px` }">
        #{{ item.id }} - {{ item.width }}px
      </div>
    </template>
  </VirtualScroll>
</template>

<style scoped>
.h-dyn {
  block-size: 160px; /* rows span the viewport height */
  border: 1px solid oklch(50% 0 0 / 0.2);
}
.card {
  box-sizing: border-box;
  block-size: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  padding: 0 1rem;
  border-inline-end: 1px solid oklch(50% 0 0 / 0.1);
}
</style>

3. Accept the estimate-then-measure pipeline

Only cells that are actually mounted can be measured, so the engine cannot know the width of an item that has never been in the viewport. Until a row mounts it keeps the fallback default-item-size (default 40), which drives the initial range and the total scroll width; as cells enter the window their real measurements replace the estimate, which is why a dynamic list "settles": the first paint (and any deep scrollToIndex) can be slightly off and correct itself over a couple of frames. Set default-item-size near your average width to shrink the initial error.

#0
Dynamic Item 0
Width: 150px
#1
Dynamic Item 1
Width: 300px
#2
Dynamic Item 2
Width: 150px
#3
Dynamic Item 3
Width: 300px
#4
Dynamic Item 4
Width: 150px
#5
Dynamic Item 5
Width: 300px
#6
Dynamic Item 6
Width: 150px
#7
Dynamic Item 7
Width: 300px
#8
Dynamic Item 8
Width: 150px
#9
Dynamic Item 9
Width: 300px
#10
Dynamic Item 10
Width: 150px
#11
Dynamic Item 11
Width: 300px
#12
Dynamic Item 12
Width: 150px
#13
Dynamic Item 13
Width: 300px
#14
Dynamic Item 14
Width: 150px
#15
Dynamic Item 15
Width: 300px
#16
Dynamic Item 16
Width: 150px
#17
Dynamic Item 17
Width: 300px
#18
Dynamic Item 18
Width: 150px
#19
Dynamic Item 19
Width: 300px
  • Scroll Status
  • Direction
    horizontal
  • Current Item #
    -
  • Rendered Range #
    0:0
  • DOM Items #
  • Total Size (px)
    0w
  • Viewport Size (px)
    0w
  • Scroll Offset (px)
    0x
  • Controls