Virtual
Render long lists, tables, grids, and galleries efficiently with windowed scrolling.
Overview
When a list, table, or gallery grows past a few hundred items, rendering them all up front becomes slow — with initial paint stalls, scrolling stutters, and climbing memory. The Virtual plugin solves this by only mounting the items currently visible in the scroll viewport, plus a small overscan buffer for smooth scrolling. A collection of ten thousand items can keep just twenty or so in the DOM at any time while the user scrolls through smoothly.
Items render with the layout you already use — a stacked list, a native <table>, a CSS grid, a flex-wrap gallery, or a masonry wall — so virtualizing doesn't change your markup or break column alignment, sticky headers, or shared cell styling.
Each item is measured as it renders, so tall items (multi-line text, embedded media, expanded details) coexist with short ones without configuration. There's no fixed-size requirement.
Virtualization is a rendering optimization, not a data-fetching one. The full data array stays in memory, and only its DOM representation is windowed. For server-side pagination see databases.
Setup
Virtual is included in manifest.js with all core plugins, or can be selectively loaded.
<script src="https://cdn.jsdelivr.net/npm/mnfst@latest/lib/manifest.min.js"></script>
Basic Usage
Wrap an x-for template in a scrolling container marked with x-virtual. The container needs a bounded height like a pixel value, percentage, viewport unit, or flex layout in order to support a scroll viewport.
<div x-data="{ team: /* 500 entries */ }">
<div x-virtual class="h-[300px] overflow-auto">
<template x-for="member in team" :key="member.id">
<div>
<p x-text="member.name"></p>
<small x-text="member.role"></small>
</div>
</template>
</div>
</div>
Five hundred items are in the array, but only the visible window is rendered as you scroll. The plugin sets overflow: auto on the container automatically if it isn't already scrollable.
A single <template> child is supported per x-virtual container, and the template's x-for and :key are consumed by the plugin (Alpine doesn't double-render them). The template may be nested — for example inside <table><tbody> — and the plugin will still find it.
Layout
The same x-virtual adapts to whatever markup fits your data — a native table, a CSS grid, a wrapping gallery, or a masonry wall. The plugin detects the layout automatically; masonry is the one mode you set explicitly.
Table
Put x-virtual on the scroll container and let the x-for template live inside <tbody>. Columns align across rows and <thead> can be sticky, exactly as in a normal table.
<div x-virtual class="h-[300px] overflow-auto">
<table class="table-fixed w-full">
<thead>
<tr><th>Name</th><th>Email</th><th>Role</th></tr>
</thead>
<tbody>
<template x-for="user in $x.users" :key="user.id">
<tr :class="user.id % 2 ? 'bg-surface-1' : ''">
<td x-text="user.name"></td>
<td x-text="user.email"></td>
<td x-text="user.role"></td>
</tr>
</template>
</tbody>
</table>
</div>
Stripe by a value from your data — here user.id — rather than :nth-child, which counts only the rows currently rendered and so flickers as you scroll. Give columns explicit widths (table-fixed) so they don't shift either.
Grid
Manifest's .grid-table is a display: grid container with display: contents rows, so every cell shares one grid. Set the columns on the container and mark each row .grid-row.
<div x-virtual class="grid-table grid-cols-[8rem_1fr_7rem] h-[300px] overflow-auto">
<div class="grid-header">
<div>Name</div><div>Email</div><div>Role</div>
</div>
<template x-for="user in $x.users" :key="user.id">
<div class="grid-row">
<div x-text="user.name"></div>
<div x-text="user.email"></div>
<div x-text="user.role"></div>
</div>
</template>
</div>
.grid-table is itself the grid, so you only set the columns. Keep those tracks content-independent (px, %, or minmax(0, 1fr)) so columns hold steady as the window changes.
Gallery
A gallery packs many items per line. The plugin auto-detects a flex-wrap container; for a CSS grid, add mode: 'gallery' so it isn't read as a table. Either way only the visible items mount — ragged widths and heights are fine. Seed estimate with an approximate item size for a steadier first paint.
<div x-virtual="{ estimate: { width: 160, height: 120 } }"
class="flex flex-wrap content-start gap-2 h-80 overflow-auto">
<template x-for="tile in $x.tiles" :key="tile.id">
<div class="center rounded text-white text-sm font-semibold"
:style="`width: ${tile.w}px; height: ${tile.h}px; background: ${tile.color}`"
x-text="'#' + tile.id"></div>
</template>
</div>
For a CSS grid instead, set mode: 'gallery' so the columns come from grid-template-columns:
<div x-virtual="{ mode: 'gallery', estimate: { height: 120 } }"
class="grid grid-cols-4 gap-2 h-80 overflow-auto">
<template x-for="tile in $x.tiles" :key="tile.id">
<div class="center rounded text-white" :style="`height: 120px; background: ${tile.color}`"
x-text="'#' + tile.id"></div>
</template>
</div>
Masonry
Masonry packs items of independent sizes into the shortest column — the layout where the occasional showcase item spans extra width or height. Set mode: 'masonry' and either a columnWidth or a fixed columns count.
<div x-virtual="{ mode: 'masonry', columnWidth: 220, gap: 12, span: block => block.span, height: block => block.height }"
class="relative h-96 overflow-auto">
<template x-for="block in $x.blocks" :key="block.id">
<div class="center rounded text-white text-sm font-semibold"
:style="`height: ${block.height}px; background: ${block.color}`"
x-text="'#' + block.id"></div>
</template>
</div>
Masonry positions items absolutely and sizes the container for you. Pass a height function (matching the item's own height) for the smoothest first paint, since the layout can then be computed before anything is measured.
| Property | Type | Default | Description |
|---|---|---|---|
columnWidth |
Number | — | Target column width in px; the column count is derived from the container width. |
columns |
Number | — | A fixed number of columns. Use this or columnWidth. |
gap |
Number | CSS gap |
Pixel gap between items. |
span |
Function | () => 1 |
Maps an item to how many columns it spans, for wide showcase items. |
height |
Function | — | Maps an item to its height in px. Supplying it skips measurement; otherwise heights are measured on first render. |
Customization
Pass options as an object expression on the directive.
| Property | Type | Default | Description |
|---|---|---|---|
estimate |
Number or { width, height } |
50 |
Initial size for unmeasured items. A closer estimate produces less scroll-position drift on first paint. Galleries and masonry accept an object to seed both axes. |
overscan |
Number | 3 |
Items (or lines) to render above and below the visible window. Higher values smooth out fast scrolling at the cost of more DOM. |
mode |
String | auto | How items fill lines: rows (one per line — lists, tables, grids), gallery (many per line — flex-wrap or CSS grid), or masonry. Auto-detected from the container for all but masonry, which is always explicit. |
<div x-virtual="{ estimate: 80, overscan: 5 }" class="h-[600px] overflow-auto">
<template x-for="item in $x.products" :key="item.id">
<div>...</div>
</template>
</div>
Without these options the defaults will work for most uniform collections. Tune estimate higher when items are tall (cards, images), and raise overscan if you see brief blank flashes during fast wheel-scrolling.
Mixed Sizes
Items with variable content like multi-line descriptions, embedded images, or expanded details work without any extra configuration. Each item is measured on first render and the scroll offsets adjust to its actual size. There is no fixed-size requirement.
<div x-virtual class="h-[600px] overflow-auto">
<template x-for="log in $x.logs" :key="log.id">
<div>
<span x-text="log.time"></span>
<span x-text="log.level"></span>
<span x-text="log.message"></span>
</div>
</template>
</div>
In this example, short and tall log lines coexist correctly. The scroll position stays accurate, the scrollbar reflects real total height, and items snap to their measured offsets the first time each one becomes visible.
Dynamic Data
Point the template's x-for at any reactive source — a local or cloud data source through $x, or plain Alpine state — and the window stays in sync. Adds, deletes, sorts, and filters update the visible items automatically, and items re-bind by key, so reordering the data never scrambles their content.
That makes virtualization a natural pair with $search and $query: wire a search input to $x.<source>.$search() and the list re-renders against the filtered results without rebuilding the whole DOM.
<div x-data="{ term: '' }">
<input type="text" placeholder="Filter products..." aria-label="Filter products" x-model="term">
<div x-virtual class="h-[600px] overflow-auto">
<template x-for="product in $x.products.$search(term, 'name')" :key="product.id">
<div>
<p x-text="product.name"></p>
<small x-text="'$' + product.price"></small>
</div>
</template>
</div>
</div>
Article does not exist
There is no documentation at this path.