Theme
On this page (10)
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 the data.

Web Platform APIs

contain: strictResizeObserveraria-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.

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

§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