Skip to content
Twenty One Media
webSeptember 23, 2026

The Evidence Panel Remembers Who Opened It

The Prism hero has eight evidence cards. You click one, a detail panel slides in below, and a heading inside it receives focus. Press the X button to close it, and focus goes... somewhere reasonable. That last part is the hard part.

The naive version of this gets the open half right and leaves the close half to chance. Focus lands on the document body, or the top of the page, and the user has to find their place again. For a keyboard user or screen reader user, that is a dead end.

We solved it with a ref that holds a pointer to the button that triggered the open.

const detailInvoker = useRef<HTMLButtonElement | null>(null);

function showEvidence(id: string, button: HTMLButtonElement) {
  detailInvoker.current = button;
  setSelected(id);
}

function closeEvidence() {
  setSelected(null);
  detailInvoker.current?.focus();
}

When any card calls showEvidence, it passes its own DOM node as button. We store that node in a ref. When closeEvidence runs, we call .focus() on whatever is in the ref. The panel closes and the cursor lands on the card that opened it.

A ref is the right container for this, not state. Storing a DOM node in useState would trigger a re-render every time a card is clicked. A ref holds the value without telling React anything changed.

Getting the heading focusable

The detail panel heading is an h3. Headings are not focusable by default: the browser's tab order skips them. We need to move focus there programmatically when the panel opens, so screen readers announce it, but we do not want it in the manual tab sequence.

tabIndex={-1} solves both problems. It makes the element reachable via .focus() in JavaScript but invisible to the Tab key.

<h3 tabIndex={-1} ref={detailHeading}>{selectedCard.title}</h3>

Coordinating the timing

We move focus to the heading inside a useEffect that fires when selected changes:

useEffect(() => {
  if (selected) detailHeading.current?.focus();
}, [selected]);

This runs after React has committed the new DOM to the screen. Calling .focus() before the element renders does nothing, so an effect is the right place for it.

Why the invoker ref beats other options

One alternative: use document.activeElement at click time. This works in simple cases but breaks if something else shifts focus between the click and the close. Another alternative: traverse the DOM looking for a button that matches the card id. That is fragile and skips the point.

A ref is explicit. It says: when this panel opens, remember exactly who asked for it. When it closes, return to that person. No inference, no search, no guessing.

The full interaction

  1. User tabs to card 03, presses Enter.
  2. showEvidence saves that card's DOM node, sets selected.
  3. React renders the detail panel. The effect fires. Focus moves to the panel heading.
  4. Screen reader announces the heading text.
  5. User reads the detail, presses the close button.
  6. closeEvidence sets selected to null, calls .focus() on the saved ref.
  7. Focus returns to card 03.

That last step is what closes the loop. Without it, the user is navigating blind.