After virtualizing a 10,000-row React table, users report that the browser's Ctrl+F no longer finds rows and screen-reader users hear the wrong number of items. Why does windowing cause this, and what would you do about it?
answer
- the DOM is now a view, not the data
- browser features walk the document
- counts must be declared, not counted
- focus dies with the element that held it
- give print its own unwindowed path
basics
~20 sNative find-in-page, assistive-technology counts, printing and select-all all read the DOM, and windowing puts only a slice of rows there. Native find cannot be restored; you replace it with in-app search that scrolls to a match, declare true counts with ARIA, and manage focus across unmounts.
solid answer
~50 sEvery browser feature that walks the document — find-in-page, print, select-all-and-copy, anchor scrolling — sees only the mounted window, and a screen reader builds its tree from the same slice, so a list of 10,000 is announced as "item 3 of 24". The mitigations are deliberate replacements rather than fixes. Provide in-app search or filtering that narrows the data set and scrolls the match into view, because native Ctrl+F cannot be recovered. Declare real counts through ARIA: `aria-rowcount` and `aria-rowindex` on a grid, or `aria-setsize` and `aria-posinset` on list items. Keep keyboard navigation on a roving tabindex driven by an active index in state, and scroll a row into view *before* moving focus to it, since focusing a row that has just unmounted drops focus to the body. Offer a separate unwindowed path for print and export. Some of this is real cost — say so when you propose virtualization.
go deeper
Know that only the rows in the window exist in the DOM, so anything the browser does by searching the page — find-in-page, print, select all — sees just that slice.
Explain why a screen reader announces the rendered count, and how ARIA attributes such as aria-setsize and aria-posinset let you declare the real size instead.
Show a working focus strategy — roving tabindex driven by an active index, scroll-into-view before focusing — and give print and export their own unwindowed rendering path.
Price the accessibility and support burden before adopting windowing broadly, and set the team's position on which lists earn it and what every virtualized list must ship with.
## The root cause in one sentence Windowing makes the DOM a *view* of the data instead of a *representation* of it. Every platform feature built on the assumption that the document contains the content therefore degrades. Listing those features precisely is what separates a candidate who has shipped a virtualized list from one who has read about it. ## What actually breaks **Find-in-page.** The browser searches rendered nodes. Row 7,412 is not there, so Ctrl+F reports nothing. There is no API to extend native find into virtual content; this is a permanent capability loss. **Assistive-technology semantics.** A screen reader builds its accessibility tree from the DOM. With 24 rows mounted, a list is announced as having 24 items and "item 3 of 24" instead of "3 of 10,000", so the user loses all sense of position and size. **Focus.** Focus lives on an element. When the focused row scrolls out of the window it is unmounted, the browser drops focus to the document body, and keyboard navigation silently resets to the top of the page. Any restore-focus-on-close logic pointing at a row breaks the same way. **Selection, copy and print.** Dragging across the list selects only what is mounted. Printing prints the window, not the list. A "copy all rows" action copies a couple of dozen. **Deep links and anchors.** Navigating to a fragment such as `#row-7412` does nothing, because no element with that id exists yet. **Scroll restoration.** Returning via the back button restores a pixel offset computed against a different measurement state, which is especially wrong with variable heights. ## The mitigations, honestly labelled **Replace find, do not patch it.** Ship a search field that filters or highlights against the *data*, then scrolls to the matched index. Announce the match count in a live region. This is better than native find for large data anyway — it can search fields that are not even rendered — but be explicit that it is a replacement, not parity. **Declare the real size with ARIA.** Use roles that carry counts and let the attributes contradict the DOM, which is exactly what they exist for: - Grid or table pattern: `role="grid"` with `aria-rowcount` on the grid and `aria-rowindex` on each rendered row, 1-based and absolute. - List or listbox pattern: `aria-setsize` and `aria-posinset` on each rendered option. ```jsx <div role="row" aria-rowindex={index + 1}>…</div> ``` Without these the announced count is simply wrong; with them the user hears "row 7,412 of 10,000" while only 24 rows exist. **Own focus explicitly.** Use a roving tabindex: exactly one row is tabbable, and which one is derived from an active index held in state, not from wherever focus happens to be. On arrow-key navigation, update the index, ensure that index is scrolled into view so it is mounted, and only then move focus to it. If the active row can be unmounted by a scroll the user performs, either keep that index rendered regardless of the window or move focus back to the list container so it is not lost entirely. **Give print and export their own path.** Do not try to make the windowed component print. Render a plain unwindowed table into a print route, or export CSV or PDF from the data. This is usually less work than any alternative and produces a better artifact. **Keep semantics valid.** Rows positioned absolutely inside a sizer often cannot be genuine `<tr>` children of a `<tbody>`, which is why many virtualized tables use ARIA grid roles on `div` elements instead. If you keep the native table, check that the sizer does not break the required element structure. **Selection lives in data.** Track selected ids in state, never by reading checked inputs out of the DOM, so "select all" and the selected count mean the whole data set. ## The judgment an interviewer wants The strongest answer ends by pricing this. The mitigations are ongoing work — every new row interaction must be re-checked against the fact that most rows do not exist. If a list is 300 rows at p95, that price probably is not worth paying, and a cheaper row or a paginated view is the better engineering decision. Virtualization is a technique with an accessibility bill attached, not a free optimization.
- A keyboard user presses the down arrow repeatedly and focus disappears after about twenty rows. What is happening?The focused row scrolled out of the window and was unmounted, so the browser moved focus to the document body. Drive navigation from an active index in state with a roving tabindex: on each key press update the index, scroll that index into view so the row is mounted, and then focus it. Never rely on the focused element still existing after a scroll.
- Which ARIA attributes let a virtualized grid report a size the DOM does not have?`aria-rowcount` on the element with `role="grid"` states the total number of rows, and `aria-rowindex` on each rendered row gives its absolute 1-based position. For a list or listbox pattern the equivalents are `aria-setsize` and `aria-posinset` on each item. They exist precisely for content that is paged or windowed, so contradicting the rendered count is correct usage.
- Can you make native Ctrl+F work across a virtualized list?No. The browser searches rendered nodes and offers no hook for content you have not mounted. The workable answer is an in-app search that queries the data, reports a match count in a live region, and scrolls the match into view. Say plainly that this is a capability trade, not a workaround that restores parity.
saying these in an interview costs you the question
- Claims ARIA attributes restore native find-in-page
- Assumes focus survives the focused row being unmounted
- Reads selection state out of DOM checkboxes
- Sets aria-setsize to the number of rendered rows
- Treats virtualization as having no accessibility cost