The exact problem: a 400-row form is virtualised for speed, the reader scrolls past a row they filled in, and the value is gone — because the row was unmounted and the value lived in the row.

Context and Prerequisites

The decision to virtualise at all should follow the measurement in performance and scale for large forms, and the cheaper option — content-visibility — should be ruled out first, since it keeps every field in the document. This page assumes virtualisation is genuinely needed: hundreds of rows, and layout dominating the profile.

The prerequisite is absolute: form state must already live outside the components. A virtualised form whose values live in row state is a form that discards data by design.

Core Pattern

interface VirtualFormOptions {
  readonly total: number;
  readonly rowHeight: number;
  readonly overscan: number;
}

/**
 * The render window is a UNION, not a range. Three indices must stay mounted
 * regardless of scroll position, and each corresponds to a real bug when it is
 * allowed to unmount.
 */
export function renderWindow(
  scrollTop: number, viewportH: number, o: VirtualFormOptions,
  pinned: { focused: number | null; errored: readonly number[]; linkedFromSummary: number | null },
): Set<number> {
  const first = Math.max(0, Math.floor(scrollTop / o.rowHeight) - o.overscan);
  const last = Math.min(o.total - 1,
    Math.ceil((scrollTop + viewportH) / o.rowHeight) + o.overscan);

  const set = new Set<number>();
  for (let i = first; i <= last; i++) set.add(i);

  // 1. The focused row: unmounting it drops focus to <body>, and the next Tab
  //    restarts from the top of the page.
  if (pinned.focused !== null) set.add(pinned.focused);
  // 2. Rows carrying errors: an error on an unmounted row cannot be announced,
  //    and its summary link resolves to nothing.
  for (const i of pinned.errored) set.add(i);
  // 3. The row a summary link just targeted, until focus lands in it.
  if (pinned.linkedFromSummary !== null) set.add(pinned.linkedFromSummary);
  return set;
}

Rendering a set rather than a range means the pinned rows are absolutely positioned at their true offsets and simply exist outside the visible band. They cost three DOM subtrees, which is the price of not breaking focus and announcements.

The render set is the visible band plus three pinned rows The visible band covers the rows currently in the viewport plus an overscan margin above and below. Three additional rows are kept mounted wherever they are in the list. The focused row, because unmounting the element that holds focus drops focus to the document body and restarts tab order from the top of the page. Any row carrying a validation error, because an error on an unmounted row cannot be announced and its summary link resolves to nothing. And the row a summary link has just targeted, until focus has actually landed inside it. the list, 400 rows row 12 — focused rows 140–158 the visible band row 201 — has an error row 388 — summary target everything else is unmounted Why each pinned row is pinned focused — unmounting drops focus to <body> and the next Tab restarts from the page top errored — an unmounted error cannot be announced and its summary link resolves to nothing summary target — kept until focus lands in it otherwise the jump arrives before the row exists Cost: three extra subtrees. Benefit: focus and announcements keep working. What virtualising takes away, and what replaces it The browser's own find-in-page no longer reaches off-screen rows, so the form has to provide a filter and say that it exists. FormData no longer sees them, so the payload comes from the store. Native constraint validation cannot reach them, so all validation runs over the store. Tab order no longer includes them, which is correct — they are not in the document — but it means the error summary becomes the only route to a failing row that is out of view. What is lost What replaces it find-in-page an in-form filter, and a note that it exists FormData sees the rows the payload is built from the store native required validation all validation runs over the store rows in tab order the error summary becomes the route to them Each row is a real capability, not a technicality — which is why content-visibility, which keeps all four, is worth trying first.

Step-by-Step Walkthrough

  1. Move values out of the rows first. If this is not already true, stop — virtualising is not the next step.

  2. Render a set, not a range. The pinned indices are what keep the form usable.

  3. Position rows absolutely at index × height. A scroll container with a spacer of the full height keeps the scrollbar honest.

  4. Give every row a stable key. Recycling a DOM node between rows without a key change moves one row’s ARIA state onto another.

  5. Announce the size. aria-rowcount and aria-rowindex on the rows tell a screen reader that there are 400 rows and this is number 141, which the DOM alone no longer says.

  6. Do not virtualise the error summary. It lists only failing rows, and it is the reader’s map back into the list.

Failure Modes and Edge Cases

1. Submit sees only the rendered rows

FormData reads the DOM, so a virtualised form cannot use it for the rows. Build the payload from the external store and use FormData only for the non-virtualised parts.

2. Native required validation stops working

Off-screen fields are not in the document, so the browser cannot validate them. All row validation has to be your own, run over the store.

3. Find-in-page misses rows

The reader’s own search no longer finds unmounted content. Provide an in-form filter, and say that it exists — otherwise readers conclude the data is missing.

4. Variable row heights

A row whose height depends on whether it shows an error changes the scroll mapping the moment validation runs. Measure and cache heights, or reserve the message space in every row.

5. Scroll anchoring fights the window

Browsers try to preserve the reader’s scroll position when content above changes. With absolutely positioned rows this can produce a fight; overflow-anchor: none on the container settles it.

The scroll test that catches everything Focus a field in row twelve and type something into it. Scroll the container two thousand pixels so the row is far outside the window. Check that document.activeElement is still that input and that the value is still in the store. Scroll back and check the value is rendered again. Four steps, and they exercise the pinned-row union, the external state and the re-render path in one pass. The scroll test that catches everything focus and type row 12, a real value in a real field scroll 2000px the row leaves the render window assert activeElement is still it, value still in the store scroll back the value renders again, unchanged If any of the four fails, the render set is a range rather than a union — which is the bug this whole page exists for.

Verification Checklist

Common Pitfalls

  • Virtualising before moving state out. A virtualised form whose values live in the row components discards data by design, and the loss is silent — the reader only finds out at submit.
  • Rendering a range instead of a set. The focused row, the errored rows and the summary link target all have to stay mounted wherever they are, and a contiguous range cannot express that.
  • Recycling nodes without changing the key. A reused DOM node carries the previous row’s ARIA state and its aria-invalid, so the error appears to move to a different row.
  • Variable row heights. A row that grows when its message appears changes the scroll mapping at exactly the moment the reader is trying to reach it. Reserve the message space in every row, or measure and cache.
  • Reaching for virtualisation first. content-visibility: auto keeps every field in the document — so submit, find-in-page and native validation all keep working — for one CSS declaration plus a size hint.

Related

Performance and Scale for Large Forms

Frequently Asked Questions

Can I use FormData with a virtualised form?

Not for the virtualised rows — FormData reads the DOM, and unmounted rows are not in it. Build the row payload from the external store, and use FormData only for the surrounding fields that are always rendered. This is a straightforward consequence of virtualising, but it catches teams who added virtualisation to an uncontrolled form and found submissions silently shrinking.

How do I keep native required validation working?

You cannot, for off-screen rows, because the browser only validates elements in the document. Once a form is virtualised, all row-level validation has to run over the store rather than over the DOM. That is usually already true for anything with a schema; it is a real loss only for forms that were relying entirely on native constraint attributes.

Is content-visibility a real alternative?

For most large forms, yes, and it should be tried first. content-visibility: auto keeps every field in the document — so submit, find-in-page and native validation all keep working — while skipping the rendering work for off-screen content. It is one CSS declaration plus a contain-intrinsic-size hint, against a state migration and four accessibility problems for true virtualisation.