scrollIntoView Waits for React
When a user picks a question in the Prism demo, the answer panel below the evidence grid needs to scroll into view. The naive version breaks in a specific, confusing way.
What breaks if you skip the frame
function ask(index: number) {
setActive(index);
setSelected(null);
answerPanel.current?.scrollIntoView({ block: "nearest", behavior: "auto" });
}
React batches state updates and flushes them in the next paint. When you call scrollIntoView synchronously here, the DOM still reflects the previous state: the answer panel may be empty (first activation), collapsed, or sized at the wrong height. The browser scrolls to whatever it finds right now, then React paints the real content at a different size. The result is a scroll that lands in the wrong place, followed by a layout shift that strands the user mid-page.
The fix is one wrapper:
requestAnimationFrame(() =>
answerPanel.current?.scrollIntoView({ block: "nearest", behavior: "auto" })
);
requestAnimationFrame fires after the browser has painted the current frame. By then React has flushed the state update, the DOM has the new content, and the panel has its real height. The scroll lands where it belongs.
block: "nearest", not "start"
The scroll option matters as much as the timing. block: "nearest" only moves the element into view if it is outside the viewport. If the answer panel is already on screen, nothing happens.
block: "start" always scrolls, snapping the element to the top of the visible area. That is wrong here: if a user is reading the current answer and clicks a different question, block: "start" yanks the page so the answer panel tops the screen, discarding their reading position. block: "nearest" is the minimum scroll: it moves only when the panel is actually off-screen.
Focus stays where it is
ask() does not call focus(). Keyboard focus stays on whichever question button the user just pressed. The user made a selection and is still at the selection control. Moving focus to the answer panel would strand them there, requiring an extra Tab or Shift-Tab to get back to the question list.
The evidence card is different. When a user opens a card's detail panel, we do move focus:
useEffect(() => {
if (selected) detailHeading.current?.focus();
}, [selected]);
That panel is a focal context: it has its own close button and content to navigate within. Taking focus is correct there. In ask(), the user is browsing between questions, not entering a new context, so we scroll without stealing focus.
The rule: scroll when content needs to be visible. Move focus when the user enters a new interaction context. Those are different things. Mixing them is the most common keyboard accessibility mistake in interactive UIs.
The full pattern
function ask(index: number) {
setActive(index);
setSelected(null);
requestAnimationFrame(() =>
answerPanel.current?.scrollIntoView({ block: "nearest", behavior: "auto" })
);
}
Three lines. Each word carries weight: requestAnimationFrame for post-paint timing, block: "nearest" for minimum movement, behavior: "auto" for instant response rather than a smooth scroll that lags behind a click. Remove any one of them and the behavior degrades in a different way.