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". Hand it records instead of a count and a defuss-dataview source backs it: filters and sorting run locally over every record, and the query lives in the list's store.
On this page (12)
§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.
§Records: filter and sort locally
setSource hands the list 250,000 records behind a defuss-dataview source. The input and the select are bound by data-virtual-list-filter / data-virtual-list-sort - no listener code: every keystroke filters ALL records (case-insensitive; 'age' takes > 40, <= 30 …), sorting reorders them, and the window shows the result. The query is the list's store: the line below subscribes to it.
§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.
§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')). |
§API
Generated from virtual-list.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type VirtualListState = 'default' | 'loading' | 'empty' - setState(name, config) takes the config of the state it names.
| State | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
default | Rows rendered.
| ||||||||||||
loading | Placeholder rows; the list is aria-busy.
| ||||||||||||
empty | No rows - the data-empty-text shows.
|
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends VirtualListState>(name: S, config?: VirtualListStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: VirtualListState; config: VirtualListStateConfigs[VirtualListState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: VirtualListState; config: VirtualListStateConfigs[VirtualListState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: VirtualListState; config: VirtualListStateConfigs[VirtualListState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.virtualListApi.setState<S extends VirtualListState>(el: HTMLElement, name: S, config?: VirtualListStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.virtualListApi.getState(el: HTMLElement): { name: VirtualListState; config: VirtualListStateConfigs[VirtualListState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.virtualListApi.render(state: { name: VirtualListState; config: VirtualListStateConfigs[VirtualListState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.virtualListApi.store(el: HTMLElement): Store<{ name: VirtualListState; config: VirtualListStateConfigs[VirtualListState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.virtualListApi.commit<S extends VirtualListState>(el: HTMLElement, name: S, config?: VirtualListStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.virtualListStates: VirtualListState[] | The declared states, 'default' first: default, loading, empty. |
df$.shadcn.virtualList
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
setData(list: HTMLElement, count: number, renderRow?: (row: HTMLElement, index: number) => void): void | An index range instead of records: count rows, renderRow(row, index) fills a recycled element - nothing is stored per row.
| ||||||||||||
setSource(list: HTMLElement, rows: DataviewRow[], { render, idField = 'id', query = {} }: VirtualListSourceOptions = {}): void | Records instead of a count: `rows` is any array of objects, `render(el, record, { index })` fills a recycled element. Filters and multisort run over every row (defuss-dataview); `query` is the first one.
| ||||||||||||
query(list: HTMLElement, query: VirtualListQuery): void | Run a query (merged into the stored one): { filters?, sorters? }.
| ||||||||||||
rows(list: HTMLElement): DataviewRow[] | The rows the current query shows.
Returns |
Types
| Type | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
VirtualListQuery | A query over the list's records - merged into the stored one.
| ||||||||||||
VirtualListSourceOptions | What setSource() takes besides the records.
|
§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; /* the windowing owns the scroll position: Firefox's scroll anchoring would follow the recycled rows, move scrollTop, re-render and move it again - a scroll that runs on by itself (big-data-firefox.e2e) */ overflow-anchor: none; 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").//// Two ways to feed it: setData(count, renderRow) - an index range, nothing// stored - or setSource(rows, { render }) - an array of records behind a// defuss-dataview source (src/shared/dataview.ts): filters and multisort run// locally over every row, the query lives in the store// (el.store.value.config.filters / .sorters) and the window shows its result.// 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, defussQuery, componentState, bindComponent, dataSource, parseFilter, virtualWindow, sizerHeight, scrollTopFor } from '../../../shared/state-api.js';import type { DataviewFilter, DataviewRow, DataviewSorter } from '../../../shared/dataview.js';const df$ = defussGlobals();const dfDollar = defussQuery();// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type// this file's API docs state - arguments, return values, event details - holds// against its code: a wrong one is a new type error and fails the build./** A query over the list's records - merged into the stored one. */type VirtualListQuery = { /** filters over every record */ filters?: DataviewFilter[]; /** the order - several sorters sort by each in turn */ sorters?: DataviewSorter[];};/** What setSource() takes besides the records. */interface VirtualListSourceOptions { /** fill a recycled row element for a record (default: the id as text) */ render?: (el: HTMLElement, record: DataviewRow, context: { index: number }) => void; /** the id field (default 'id') */ idField?: string; /** the first query */ query?: VirtualListQuery;}const virtualListStates = ['default', 'loading', 'empty'];/** setState() configs per state - merged into the stored one ({ index } keeps the query). */export interface VirtualListStateConfigs { /** Rows rendered. */ default: { /** scroll this row into view (a one-off: not kept in the stored config) */ index?: number; /** with a source: filters over every record */ filters?: DataviewFilter[]; /** with a source: the order */ sorters?: DataviewSorter[]; }; /** Placeholder rows; the list is aria-busy. */ loading: { /** with a source: filters over every record */ filters?: DataviewFilter[]; /** with a source: the order */ sorters?: DataviewSorter[]; }; /** No rows - the data-empty-text shows. */ empty: { /** with a source: filters over every record */ filters?: DataviewFilter[]; /** with a source: the order */ sorters?: DataviewSorter[]; };}/** 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 items the list shows: the source's query result, else the count. */const itemCount = (list) => (list._source ? list._result.entries.length : list._count);/** How many rows the items occupy — the unit the window is measured in. */const rowCount = (list) => Math.ceil(itemCount(list) / columnsOf(list));/** Height given to the sizer — clamped so the browser can render it (shared/virtual.ts). */const listSizer = (list) => sizerHeight(rowCount(list), list._rowHeight);/** fill one recycled element with item `index` */function fill(list, el, index) { if (!list._source) return list._renderRow(el, index); const entry = list._result.entries[index]; list._render(el, entry.row, { index, ...entry.meta });}/** Writes the window of rows that belongs at the current scroll position. */function renderRows(list) { const rows = list._rows; if (!rows) return; const count = itemCount(list); const cols = columnsOf(list); const total = rowCount(list); const { first, count: pool, shift } = virtualWindow(list.scrollTop, list.clientHeight, list._rowHeight, total); // grow/shrink the recycled pool to the window size while (rows.children.length < pool) { const row = document.createElement('div'); row.className = 'virtual-list-row'; row.setAttribute('role', cols > 1 ? 'row' : 'listitem'); dfDollar(rows).append(row); } while (rows.children.length > pool) { rows.lastElementChild.remove(); } // The pool sits inside a translated wrapper: one translate per scroll frame // instead of one `top` write per row. 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)); fill(list, 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'); dfDollar(row).append(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); fill(list, cell, itemIndex); } }}/** Default row content when the consumer supplies no renderer. */const defaultRenderRow = (row, index) => { row.textContent = `Row ${index + 1}`;};/** * The markup of a state, for render(): the attributes a state writes, applied * to a detached copy of the authored markup ('default' IS the authored * markup). The live element gets the same markup from triggerStateChange - * the e2e render round trip proves they agree. */function applyMarkup(el, stateName) { dfDollar(el).attr('data-state', stateName).attr('aria-busy', stateName === 'loading' ? 'true' : null);}/** * A source's query changed (or its rows): evaluate it (cached per query in * the source), size the sizer, recycle every row. Returns the item count. */function refresh(list, config) { if (list._source) list._result = list._source.query({ filters: config.filters, sorters: config.sorters }); const sizer = list._rows?.parentElement; if (sizer) sizer.style.height = `${listSizer(list)}px`; if (columnsOf(list) > 1) list.setAttribute('aria-rowcount', String(rowCount(list))); if (list._rows) { Array.from(list._rows.children as HTMLCollectionOf<HTMLElement>).forEach((row) => { row._index = -1; }); } return itemCount(list);}/** * UI side of setState: 'default' shows the rows (config `{ index }` scrolls * that row into view; with a source, `{ filters, sorters }` is the query - * a query nothing matches lands in 'empty'), 'loading' shows placeholder rows * and marks the list busy, 'empty' shows the empty message. */function triggerStateChange(list, stateName, config, incoming = config) { if (list._source && stateName !== 'loading' && !refresh(list, config) && stateName === 'default') stateName = 'empty'; list.dataset.state = stateName; // the state it lands in (a query with no match is 'empty') list.dataset.stateName = stateName; switch (stateName) { case 'default': list.removeAttribute('aria-busy'); renderRows(list); if (typeof incoming.index === 'number') { const item = Math.floor(Math.max(0, Math.min(itemCount(list) - 1, incoming.index)) / columnsOf(list)); list.scrollTop = scrollTopFor(item, list.clientHeight, list._rowHeight, rowCount(list)); renderRows(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 = componentState({ component: 'virtual-list', states: virtualListStates, // a config merges: the query stays when only { index } is passed mergeConfig: true, apply: (list, state, _previous, incoming) => triggerStateChange(list, state.name, state.config, incoming), markup: (el, state) => applyMarkup(el, state.name),});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 = { /** * An index range instead of records: count rows, renderRow(row, index) fills a recycled element - nothing is stored per row. * @param list - the .virtual-list element * @param count - how many rows (floored, at least 0; 0 shows the empty state) * @param renderRow - fills the recycled element of row `index`; omitted, the last one given stays */ setData(list: HTMLElement, count: number, renderRow?: (row: HTMLElement, index: number) => void): void { list._source = null; list._count = Math.max(0, Math.floor(count) || 0); if (renderRow) list._renderRow = renderRow; refresh(list, {}); if (list.store) virtualListApi.setState(list, list._count ? 'default' : 'empty'); }, /** * Records instead of a count: `rows` is any array of objects, `render(el, * record, { index })` fills a recycled element. Filters and multisort run * over every row (defuss-dataview); `query` is the first one. * @param list - the .virtual-list element * @param rows - the records * @param options - render, the id field and the first query */ setSource(list: HTMLElement, rows: DataviewRow[], { render, idField = 'id', query = {} }: VirtualListSourceOptions = {}): void { list._source = dataSource(rows, { idField }); list._result = list._source.query(query); if (render) list._render = render; list._render ??= (el, record) => { el.textContent = String(record[idField]); }; if (list.store) virtualListApi.setState(list, 'default', { filters: [], sorters: [], ...query }); else list._pendingQuery = query; }, /** * Run a query (merged into the stored one): { filters?, sorters? }. * @param list - the .virtual-list element * @param query - the keys to change */ query(list: HTMLElement, query: VirtualListQuery): void { virtualListApi.setState(list, 'default', query); }, /** * The rows the current query shows. * @param list - the .virtual-list element * @returns the records, in list order ([] for an index range from setData) */ rows(list: HTMLElement): DataviewRow[] { return list._source ? list._result.entries.map((entry) => entry.row) : []; },};/** * Declarative query controls, anywhere on the page (one document listener): * <input data-virtual-list-filter="list-id" data-field="name"> filters a * source-backed list (data-kind="number" for > 10 / <= 3 …), and * <select data-virtual-list-sort="list-id"> sorts it by "field:asc|desc" * values ("" = source order). Several filters on one list combine (AND). */if (!document.__virtualListQueryInit) { document.__virtualListQueryInit = true; const target = (el, attr) => dfDollar('#' + CSS.escape(el.getAttribute(attr))).get(0); document.addEventListener('input', (e) => { const input = (e.target as HTMLElement).closest?.<HTMLInputElement>('[data-virtual-list-filter]'); const list = input && target(input, 'data-virtual-list-filter'); if (!list?._source || !list.store) return; clearTimeout(list._filterTimer); list._filterTimer = setTimeout(() => { const filters = dfDollar<HTMLInputElement>(`[data-virtual-list-filter="${CSS.escape(list.id)}"]`).toArray() .map((el) => parseFilter(el.dataset.field || list._source.idField, el.value, (el.dataset.kind || 'text') as 'text' | 'number' | 'select')) .filter(Boolean); virtualListApi.setState(list, 'default', { filters }); }, 150); }); document.addEventListener('change', (e) => { const select = (e.target as HTMLElement).closest?.<HTMLSelectElement>('[data-virtual-list-sort]'); const list = select && target(select, 'data-virtual-list-sort'); if (!list?._source || !list.store) return; const [field, direction] = select.value.split(':'); virtualListApi.setState(list, 'default', { sorters: field ? [{ field, direction: direction === 'desc' ? 'desc' : 'asc' }] : [] }); });}function init() { dfDollar('.virtual-list:not([data-init])').toArray().forEach((list) => { list.dataset.init = ''; // the sizer gives the scrollbar its length; the pool rides inside it let sizer = dfDollar(list).find('.virtual-list-sizer').get(0); if (!sizer) { sizer = document.createElement('div'); sizer.className = 'virtual-list-sizer'; dfDollar(list).append(sizer); } let rows = dfDollar(sizer).find('.virtual-list-rows').get(0); if (!rows) { rows = document.createElement('div'); rows.className = 'virtual-list-rows'; dfDollar(sizer).append(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 = `${listSizer(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') renderRows(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') renderRows(list); }).observe(list); // el.store + el.api (AGENTS.md "State through stores") bindComponent(list, virtualListApi); if (list._source) virtualListApi.setState(list, 'default', { filters: [], sorters: [], ...list._pendingQuery }); else 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