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.
Step-by-Step Walkthrough
-
Move values out of the rows first. If this is not already true, stop — virtualising is not the next step.
-
Render a set, not a range. The pinned indices are what keep the form usable.
-
Position rows absolutely at index × height. A scroll container with a spacer of the full height keeps the scrollbar honest.
-
Give every row a stable key. Recycling a DOM node between rows without a key change moves one row’s ARIA state onto another.
-
Announce the size.
aria-rowcountandaria-rowindexon the rows tell a screen reader that there are 400 rows and this is number 141, which the DOM alone no longer says. -
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.
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: autokeeps 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 — deciding whether to virtualise at all
- Rendering 100-Plus Field Forms Without Jank — the cheaper options first
- Building an Accessible Error Summary — the map back into a virtualised list
← 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.