Horizontal Fixed

Horizontal scrolling with uniform item widths
Optimized for 1,000 items where every item has the same width (100px). Row height is filled automatically. Default buffers are set to 20 for smoother horizontal panning.

How to build a feature like this

To virtualize a horizontal strip in which every item has the same width, switch the axis to horizontal and give the engine a numeric item-size. Uniform sizes are the best case for virtualization: the visible window, each item's position, and the total scroll width are all derived arithmetically (index × itemSize), so range math stays arithmetic and scrolling a very long dataset stays smooth. That speed is the reward for a promise you make to the engine - that every item is exactly the declared width - so this mode is the right choice only while that promise holds.

1. Put the list on the horizontal axis and size it

direction defaults to 'vertical'; pass direction="horizontal" to scroll along the inline (width) axis. The component renders its own scrollable host, which needs a definite block size so rows have a height to span (rows stretch to the viewport height automatically) and a constrained inline size so content overflows sideways rather than wrapping. In a flex/grid parent, remember min-height: 0 / min-width: 0 so the box is allowed to shrink below its content. The buffer-before / buffer-after props (default 5) keep a few extra items mounted past each edge so fast panning does not flash blank cells; they count items, so widen them if cells are wide or travel is quick.

2. Pass a real array and render each row from item

The mainstream data model is an ordinary array of item objects. The #item slot receives both item and index; render your cell from item and use index for position or keys. Only the window in view (plus the buffer) is mounted, but the full array still lives in memory - virtualization saves DOM nodes, not your data.

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';

const items = Array.from({ length: 10_000 }, (_, i) => ({
  id: i,
  label: `Item ${ i }`,
}));
</script>

<template>
  <VirtualScroll
    virtual-scrollbar
    class="h-list"
    direction="horizontal"
    :items="items"
    :item-size="100"
    aria-label="Horizontal list"
  >
    <template #item="{ item, index }">
      <div class="h-row">
        <strong>{{ item.label }}</strong>
        <span>#{{ index }}</span>
      </div>
    </template>
  </VirtualScroll>
</template>

<style scoped>
/* A definite block size fixes the row height; rows stretch to it and the
   content scrolls sideways. The inline size comes from the layout. */
.h-list {
  block-size: 140px;
  border: 1px solid oklch(50% 0 0 / 0.2);
}

/* Each cell is item-size (100px) wide along the scroll axis. The row root must
   fill it so nothing overlaps or leaks. */
.h-row {
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  inline-size: 100%;
  block-size: 100%;
  border-inline-end: 1px solid oklch(50% 0 0 / 0.1);
}
</style>

3. Use an index-only array when rows carry no data

When a cell is fully described by its position (a tick, an ordinal sequence, a pagination page) you can skip allocating data objects entirely. items only needs to provide a length - entries are read only for the rendered window - so a sparse new Array(count) works: the item slot prop is undefined for every hole and you render from index. Paired with a numeric item-size this keeps memory flat even into the millions of rows. Do not reach for it when rows carry content or vary in size; those need real data plus an item-size function/array or dynamic measurement.

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

// A row fully described by its index needs no data: only the array length is
// used and every `item` slot prop is undefined, so render from `index`.
const items = new Array(1_000_000);
</script>

<template>
  <VirtualScroll
    virtual-scrollbar
    class="h-list"
    direction="horizontal"
    :items="items"
    :item-size="100"
    aria-label="Index-only horizontal list"
  >
    <template #item="{ index }">
      <div class="h-row">#{{ index }}</div>
    </template>
  </VirtualScroll>
</template>

4. Keep item-size equal to the rendered width

item-size is a contract, not a hint: the engine uses it for the total scroll width, the range math, and any scrollbar geometry. Each rendered row is mounted into a cell exactly that wide and as tall as the viewport, so the row root should fill the box (inline-size: 100%; block-size: 100%, borders included via box-sizing: border-box). Content wider than the declared size clips or overlaps; narrower content leaves gaps. When widths genuinely vary, move off uniform mode to an array, a function, or dynamic measurement (see the dynamic-width example) rather than fighting a fixed number.

#0
Fixed Item 0
#1
Fixed Item 1
#2
Fixed Item 2
#3
Fixed Item 3
#4
Fixed Item 4
#5
Fixed Item 5
#6
Fixed Item 6
#7
Fixed Item 7
#8
Fixed Item 8
#9
Fixed Item 9
#10
Fixed Item 10
#11
Fixed Item 11
#12
Fixed Item 12
#13
Fixed Item 13
#14
Fixed Item 14
#15
Fixed Item 15
#16
Fixed Item 16
#17
Fixed Item 17
#18
Fixed Item 18
#19
Fixed Item 19
  • 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