Theme
Design your own
On this page (12)
Component Skill — components/virtual-list/component-skill.md

Native basis

A focusable scroll container (role="list" or "grid"): a sizer gives the scrollbar its full length, a small pool of recycled rows rides inside it; df$.shadcn.virtualList.setData(el, count, renderRow) hands it an index range, setSource(el, records, { render }) hands it records behind a dataview source - the query (filters, sorters) is el.store.value.config.

Web Platform APIs

contain: strictoverflow-anchor: noneResizeObserveraria-setsize / aria-posinsetoverscroll-behavior

Classes

.virtual-list.virtual-list-sizer.virtual-list-rows.virtual-list-row.virtual-list-cell.virtual-list-row-meta

Data attributes

data-columns (a grid), data-count (a count from markup), data-empty-text; --virtual-list-row-height. Query controls anywhere on the page: data-virtual-list-filter="list-id" (+ data-field, data-kind="number") on an input, data-virtual-list-sort="list-id" on a select of field:asc|desc values.

§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.

§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 view
  • loading - aria-busy="true" and placeholder rows
  • empty - no items: the data-empty-text message

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:

StateTypeValuesDefaultDescription
loadingbooleantrue, falsefalsearia-busy with placeholder rows (setState('loading')).
emptybooleantrue, falsefalseNo 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.

StateDescription
default
Rows rendered.
Config fieldTypeDescription
index?numberscroll this row into view (a one-off: not kept in the stored config)
filters?DataviewFilter[]with a source: filters over every record
sorters?DataviewSorter[]with a source: the order
loading
Placeholder rows; the list is aria-busy.
Config fieldTypeDescription
filters?DataviewFilter[]with a source: filters over every record
sorters?DataviewSorter[]with a source: the order
empty
No rows - the data-empty-text shows.
Config fieldTypeDescription
filters?DataviewFilter[]with a source: filters over every record
sorters?DataviewSorter[]with a source: the order

Every element

MemberDescription
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.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?VirtualListStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

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 { name: VirtualListState; config: VirtualListStateConfigs[VirtualListState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state?{ name: VirtualListState; config: VirtualListStateConfigs[VirtualListState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

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

MemberDescription
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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?VirtualListStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

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.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: VirtualListState; config: VirtualListStateConfigs[VirtualListState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state{ name: VirtualListState; config: VirtualListStateConfigs[VirtualListState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.virtualListApi.store(el: HTMLElement): Store<{ name: VirtualListState; config: VirtualListStateConfigs[VirtualListState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: VirtualListState; config: VirtualListStateConfigs[VirtualListState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?VirtualListStateConfigs[S]its config
df$.shadcn.virtualListStates: VirtualListState[]The declared states, 'default' first: default, loading, empty.

df$.shadcn.virtualList

MemberDescription
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.
ArgumentTypeDescription
listHTMLElementthe .virtual-list element
countnumberhow many rows (floored, at least 0; 0 shows the empty state)
renderRow?(row: HTMLElement, index: number) => voidfills the recycled element of row `index`; omitted, the last one given stays
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.
ArgumentTypeDescription
listHTMLElementthe .virtual-list element
rowsDataviewRow[]the records
{ render, idField = 'id', query = {} }VirtualListSourceOptions = {}render, the id field and the first query
query(list: HTMLElement, query: VirtualListQuery): void
Run a query (merged into the stored one): { filters?, sorters? }.
ArgumentTypeDescription
listHTMLElementthe .virtual-list element
queryVirtualListQuerythe keys to change
rows(list: HTMLElement): DataviewRow[]
The rows the current query shows.
ArgumentTypeDescription
listHTMLElementthe .virtual-list element

Returns DataviewRow[] - the records, in list order ([] for an index range from setData)

Types

TypeDescription
VirtualListQuery
A query over the list's records - merged into the stored one.
FieldTypeDescription
filters?DataviewFilter[]filters over every record
sorters?DataviewSorter[]the order - several sorters sort by each in turn
VirtualListSourceOptions
What setSource() takes besides the records.
FieldTypeDescription
render?(el: HTMLElement, record: DataviewRow, context: { index: number }) => voidfill a recycled row element for a record (default: the id as text)
idField?stringthe id field (default 'id')
query?VirtualListQuerythe first query

§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