Virtual ListATM
A list too long to render. Only the rows on screen exist in the DOM, and their elements are recycled as you scroll - ten rows and ten million cost the same, the frame rate holds, and the last row is reachable even past the browser's element-height cap. One engine for lists and grids; screen readers still hear "row 5,000,099 of 10,000,000".
On this page (10)
§Ten million rows
Only the rows on screen exist in the DOM - about sixteen at any scroll position - and they are recycled as you scroll, so ten rows and ten million cost the same. Past the browser's height cap the scroll range is mapped, so the last row stays reachable. Every row carries aria-posinset / aria-setsize against the real length.
§Row height
--virtual-list-row-height sets the uniform row height the scroll maths reads - a continuous measurement, so no size scale: compact 28px, roomy 64px.
§Jump to a row
setState('default', with an index) scrolls any row into view - even row 5,000,000 of ten million.
§Grid
data-columns puts several items in a row (role='grid', aria-rowcount / aria-colcount); the renderer is called per cell with the ITEM index. A million photos - each cell composes the image component; seeded URLs give a recycled cell its own picture back.
§Rich rows and one listener
Rows can hold anything - an avatar, a name, a badge. Because the elements are recycled, never attach a listener per row: one delegated listener on the list reads row.dataset.index.
§Loading
setState('loading') marks the list aria-busy and shows placeholder rows; the next setData (or setState('default')) shows the rows.
§Empty
A list with no items enters the empty state and shows its data-empty-text.
§States
Named states via the shared State API, driven per instance through the bound api:
default- the rows;{ index }scrolls that row into viewloading-aria-busy="true"and placeholder rowsempty- no items: thedata-empty-textmessage
The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/virtual-list-{state}.png.
Machine contract - verified against virtual-list.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
loading | boolean | true, false | false | aria-busy with placeholder rows (setState('loading')). |
empty | boolean | true, false | false | No items - shows data-empty-text (setState('empty')). |
§CSS view file
/* -- Virtual List ----------------------------------------------- *//* Windowed list: only the rows on screen exist in the DOM. The *//* sizer supplies the scrollbar length, the pool rides inside it. */@layer components { .virtual-list { --virtual-list-row-height: 40px; position: relative; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--card); color: var(--card-foreground); /* the rows are absolutely placed inside the sizer — tell the browser it need not look outside this box when painting or laying out */ contain: strict; &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } } /* Row height is set with --virtual-list-row-height rather than a data-size scale: it is a continuous measurement the scroll maths reads, and every real use picks its own (a grid tile needs a different height from a text row). A three-value scale would only get in the way. */ /* Height stands in for every row that could exist, so the scrollbar has the right length and feel. */ .virtual-list-sizer { position: relative; width: 100%; } /* The recycled pool. Translated as a block once per frame rather than repositioning every row individually. */ .virtual-list-rows { position: absolute; inset-inline: 0; top: 0; will-change: translate; } .virtual-list-row { display: flex; align-items: center; gap: 0.75rem; height: var(--virtual-list-row-height); padding-inline: 0.875rem; font-size: 0.875rem; border-bottom: 1px solid var(--border); background: var(--card); &:hover { background: var(--accent); color: var(--accent-foreground); } } /* -- Grid (several items per row) --------------------------- */ /* data-columns turns each recycled row into a grid of cells. The row height still governs the scroll maths, so make it tall enough for a whole tile. */ .virtual-list[data-columns] { & .virtual-list-row { display: grid; grid-template-columns: repeat(var(--virtual-list-columns, 3), 1fr); gap: 0.5rem; padding: 0.25rem 0.5rem; border-bottom: none; background: transparent; &:hover { background: transparent; color: inherit; } } } .virtual-list-cell { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.25rem; min-width: 0; padding: 0.375rem; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--card); font-size: 0.75rem; text-align: center; & > span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted-foreground); } & svg { width: 1.25rem; height: 1.25rem; color: var(--foreground); } /* Composed .image keeps its own aspect ratio and object-fit; the cell only says how wide it may be. Fixed dimensions matter here because a recycled cell swaps the src rather than the element — without them every scroll frame would reflow the row as each new picture arrives. */ & > .image { width: 2.75rem; flex-shrink: 0; background: var(--muted); } &:hover { background: var(--accent); color: var(--accent-foreground); } } /* Secondary text inside a row. */ .virtual-list-row-meta { margin-inline-start: auto; font-size: 0.75rem; color: var(--muted-foreground); font-variant-numeric: tabular-nums; } /* -- Loading and empty states ------------------------------- */ /* Both replace the rows entirely, so the pool is simply hidden. */ .virtual-list[data-state="loading"], .virtual-list[data-state="empty"] { & .virtual-list-rows { display: none; } & .virtual-list-sizer { height: 100% !important; /* no scrollable content in these states */ } } /* Both overlays hang off .virtual-list itself, not the sizer: attr() reads the attribute of the element the pseudo-element belongs to, and data-empty-text is authored on the list. */ .virtual-list[data-state="loading"]::before { content: ""; position: absolute; inset: 0; /* placeholder rows, drawn rather than built from elements */ background: repeating-linear-gradient( to bottom, var(--muted) 0, var(--muted) calc(var(--virtual-list-row-height) - 1px), transparent calc(var(--virtual-list-row-height) - 1px), transparent var(--virtual-list-row-height) ); opacity: 0.6; animation: virtual-list-pulse 1.6s ease-in-out infinite; } .virtual-list[data-state="empty"]::after { content: attr(data-empty-text); position: absolute; inset: 0; display: grid; place-items: center; font-size: 0.875rem; color: var(--muted-foreground); } @keyframes virtual-list-pulse { 0%, 100% { opacity: 0.6; } 50% { opacity: 0.3; } }}@media (prefers-reduced-motion: reduce) { @layer components { .virtual-list, .virtual-list *, .virtual-list::before, .virtual-list::after, .virtual-list *::before, .virtual-list *::after, .virtual-list-row, .virtual-list-row::before, .virtual-list-row::after { transition: none; animation: none; scroll-behavior: auto; } }}@media (prefers-contrast: more) { @layer components { .virtual-list-row { border-bottom-color: var(--muted-foreground); } .virtual-list:focus-visible { outline-width: 3px; } }}@media (forced-colors: active) { @layer components { .virtual-list { border-color: ButtonText; } .virtual-list-row { border-bottom-color: ButtonText; &:hover { background: Highlight; color: HighlightText; } } .virtual-list:focus-visible { outline-color: Highlight; } }}§JS view file
// -- Virtual List ---------------------------------------------// Windowed list: only the rows on screen exist in the DOM, and their elements// are recycled as you scroll, so a list of ten rows and a list of ten million// cost the same. Plus the named-state API so agents/tests can drive states by// name (AGENTS.md "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals } from '../../shared/state-api.js';const df$ = defussGlobals();const virtualListStates = ['default', 'loading', 'empty'];/** Rows kept above and below the viewport so a fast flick never shows a gap. */const OVERSCAN = 4;/** * Browsers clamp how tall an element may be — Chrome around 33.5M px, Firefox * lower. A million 40px rows would need 40M px of sizer, past the cap, and the * end of the list would simply be unreachable. Past this threshold the sizer is * capped and scroll positions are mapped onto the real range instead, trading * scrollbar granularity (which nobody can perceive at that length) for a list * that actually reaches its last row. */const MAX_SIZER_PX = 15_000_000;/** Items per row: 1 is a list, more is a grid. */const columnsOf = (list) => Math.max(1, parseInt(list.dataset.columns || '1', 10) || 1);/** How many rows the items occupy — the unit the window is measured in. */const rowCount = (list) => Math.ceil(list._count / columnsOf(list));/** Total pixel height the rows would occupy if they all existed. */const contentHeight = (list) => rowCount(list) * list._rowHeight;/** Height given to the sizer — clamped so the browser can render it. */const sizerHeight = (list) => Math.min(contentHeight(list), MAX_SIZER_PX);/** scrollTop (capped space) -> offset into the full content (real space). */function virtualOffset(list) { const viewport = list.clientHeight; const real = contentHeight(list) - viewport; const capped = sizerHeight(list) - viewport; if (real <= 0 || capped <= 0) return 0; return (list.scrollTop / capped) * real;}/** Writes the window of rows that belongs at the current scroll position. */function render(list) { const rows = list._rows; if (!rows) return; const count = list._count; const cols = columnsOf(list); const total = rowCount(list); const rowHeight = list._rowHeight; const visible = Math.ceil(list.clientHeight / rowHeight) + OVERSCAN * 2; const offset = virtualOffset(list); let first = Math.max(0, Math.floor(offset / rowHeight) - OVERSCAN); if (first + visible > total) first = Math.max(0, total - visible); // grow/shrink the recycled pool to the window size while (rows.children.length < Math.min(visible, total)) { const row = document.createElement('div'); row.className = 'virtual-list-row'; row.setAttribute('role', cols > 1 ? 'row' : 'listitem'); rows.appendChild(row); } while (rows.children.length > Math.min(visible, total)) { rows.lastElementChild.remove(); } // The pool sits inside a translated wrapper: one translate per scroll frame // instead of one `top` write per row. const shift = first * rowHeight - (offset - list.scrollTop); rows.style.translate = `0 ${shift}px`; for (let i = 0; i < rows.children.length; i++) { const row = rows.children[i]; const index = first + i; if (row._index === index) continue; // already showing this row — leave it be row._index = index; row.dataset.index = String(index); if (cols === 1) { row.setAttribute('aria-posinset', String(index + 1)); row.setAttribute('aria-setsize', String(count)); list._renderRow(row, index); continue; } // grid: the row holds `cols` recycled cells, and the tail row may be short row.setAttribute('aria-rowindex', String(index + 1)); while (row.children.length < cols) { const cell = document.createElement('div'); cell.className = 'virtual-list-cell'; cell.setAttribute('role', 'gridcell'); row.appendChild(cell); } for (let c = 0; c < cols; c++) { const cell = row.children[c]; const itemIndex = index * cols + c; cell.setAttribute('aria-colindex', String(c + 1)); if (itemIndex >= count) { // past the last item: keep the cell for recycling, hide it from view cell.hidden = true; cell.dataset.index = ''; continue; } cell.hidden = false; cell.dataset.index = String(itemIndex); list._renderRow(cell, itemIndex); } }}/** Default row content when the consumer supplies no renderer. */const defaultRenderRow = (row, index) => { row.textContent = `Row ${index + 1}`;};/** * UI side of setState: 'default' shows the rows (config `{ index }` scrolls * that row into view), 'loading' shows placeholder rows and marks the list * busy, 'empty' shows the empty message. State lives on the element. */function triggerStateChange(list, stateName, config) { list.dataset.state = stateName; switch (stateName) { case 'default': list.removeAttribute('aria-busy'); render(list); if (typeof config.index === 'number') { const item = Math.max(0, Math.min(list._count - 1, config.index)); const real = Math.floor(item / columnsOf(list)) * list._rowHeight; const viewport = list.clientHeight; const ratio = Math.max(0, contentHeight(list) - viewport) ? (sizerHeight(list) - viewport) / (contentHeight(list) - viewport) : 0; list.scrollTop = real * ratio; render(list); } break; case 'loading': list.setAttribute('aria-busy', 'true'); break; case 'empty': list.removeAttribute('aria-busy'); break; }}/** Registry-level API; pass the list element explicitly. Unknown names throw. */export const virtualListApi = { setState(list, stateName, config = {}) { if (!virtualListStates.includes(stateName)) { throw new Error( `virtual-list: unknown state "${stateName}" (supported: ${virtualListStates.join(', ')})`, ); } triggerStateChange(list, stateName, config); // state lives on the ELEMENT, not the module: every instance on a page may // sit in a different state list.dataset.stateName = stateName; list._stateConfig = config; }, getState(list) { return { name: list.dataset.stateName || 'default', config: list._stateConfig ?? {} }; },};df$.virtualListApi = virtualListApi;df$.virtualListStates = virtualListStates;/** * Public imperative API (AGENTS.md "No window globals"): hand a list its data. * `count` may be any number the platform can hold — nothing is allocated per * row. `renderRow(rowElement, index)` fills a recycled element; it must not * assume the element is empty or new. */df$.virtualList = { setData(list, count, renderRow) { list._count = Math.max(0, Math.floor(count) || 0); if (renderRow) list._renderRow = renderRow; const sizer = list.querySelector('.virtual-list-sizer'); if (sizer) sizer.style.height = `${sizerHeight(list)}px`; if (columnsOf(list) > 1) list.setAttribute('aria-rowcount', String(rowCount(list))); if (list._rows) { Array.from(list._rows.children).forEach((row) => { row._index = -1; }); } virtualListApi.setState(list, list._count ? 'default' : 'empty'); },};function init() { document.querySelectorAll('.virtual-list:not([data-init])').forEach((list) => { list.dataset.init = ''; // the sizer gives the scrollbar its length; the pool rides inside it let sizer = list.querySelector('.virtual-list-sizer'); if (!sizer) { sizer = document.createElement('div'); sizer.className = 'virtual-list-sizer'; list.appendChild(sizer); } let rows = sizer.querySelector('.virtual-list-rows'); if (!rows) { rows = document.createElement('div'); rows.className = 'virtual-list-rows'; sizer.appendChild(rows); } list._rows = rows; list._renderRow = list._renderRow || defaultRenderRow; list._rowHeight = parseFloat(getComputedStyle(list).getPropertyValue('--virtual-list-row-height')) || 40; // Data may arrive BEFORE this element is initialized: on SPA navigation the // page's setup runs synchronously after the content swap, while this init // is a MutationObserver callback that lands afterwards. Keep what setData // stored — clobbering it here is what left a freshly navigated page empty. if (typeof list._count !== 'number') { list._count = parseInt(list.dataset.count || '0', 10) || 0; } const cols = columnsOf(list); list.setAttribute('role', cols > 1 ? 'grid' : 'list'); if (cols > 1) list.setAttribute('aria-colcount', String(cols)); if (!list.hasAttribute('tabindex')) list.tabIndex = 0; // arrow keys scroll it sizer.style.height = `${sizerHeight(list)}px`; // one render per animation frame, however many scroll events arrive let queued = false; list.addEventListener( 'scroll', () => { if (queued) return; queued = true; requestAnimationFrame(() => { queued = false; if (list.dataset.state !== 'loading' && list.dataset.state !== 'empty') render(list); }); }, { passive: true }, ); // the visible window depends on the container height, not just scrolling new ResizeObserver(() => { if (list.dataset.state !== 'loading' && list.dataset.state !== 'empty') render(list); }).observe(list); list.api = { setState: (stateName, config) => virtualListApi.setState(list, stateName, config), getState: () => virtualListApi.getState(list), }; virtualListApi.setState(list, list._count ? 'default' : 'empty'); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub