Skip to content
Twenty One Media
webSeptember 28, 2026

Eight Buttons That Control One Panel

The Prism hero has eight evidence cards arranged in a scrollable row. Any one of them can open the same detail panel at the bottom of the demo. That setup creates a specific accessibility problem: how does a screen reader user know which card is currently open, and which ones are just available to open?

The naive version breaks quickly. If you put aria-expanded on the panel itself, nothing announces it when it changes because the panel isn't the interactive element. If you put a single aria-expanded on a wrapper, you lose the per-card granularity. If you skip it entirely, the relationship between card and panel is invisible to assistive technology.

The pattern we used

Each card is a <button> with two ARIA attributes:

aria-expanded={selected === card.id}
aria-controls="prism-evidence-detail"

aria-expanded is a per-button attribute. It reflects whether this specific button has opened the panel. Seven of the eight cards read aria-expanded="false". The one that opened the panel reads aria-expanded="true". When a user opens a different card, the true/false state flips to the new card and the old one goes back to false.

aria-controls points every card at the same element ID: the single detail panel. This tells assistive technology that pressing this button affects that region. All eight buttons share the same target. That is correct: there is one panel, and any card can control it.

The panel itself gets a stable ID (id="prism-evidence-detail") and nothing else. It does not declare a role of "dialog" because it is not a dialog. It does not have aria-labelledby pointing to the card. The card's aria-controls relationship is the connection, and that is enough.

Dynamic aria-labels carry the dimming cue

When a question is active in Prism, related evidence cards get a visual cue: the rest of the grid dims and the relevant cards stay bright. Sighted users can see which two cards are evidence for the answer. Screen reader users do not see the dim state.

We solve this in the aria-label:

aria-label={`Explore ${card.source}: ${card.title}${
  related ? " — evidence for this answer" : ""
}`}

When a card is related to the active question, the label gains "evidence for this answer" at the end. The label updates whenever active changes, because it is derived from the same related boolean that drives the visual state. The screen reader user and the sighted user get the same information, through different channels.

Clicking the same card twice

One edge case: what happens when a user opens card A and then clicks card A again? The panel is already showing that card's content. A toggle would close it, but closing and reopening the panel is rarely what the user wants.

We chose to move focus to the panel heading instead:

function showEvidence(id: string, button: HTMLButtonElement) {
  detailInvoker.current = button;
  if (selected === id) detailHeading.current?.focus();
  setSelected(id);
}

If selected === id, the panel is already open for this card. Moving focus to the heading gets the user into the content they clearly want to read. If we toggled it closed, the user would have to click again to re-open it, which gains nothing.

The heading is marked tabIndex={-1} so it can receive programmatic focus without entering the natural tab order. We covered that pattern in the evidence panel focus return post.

Why this is not a tabs pattern

The ARIA tabs pattern (role="tablist", role="tab", role="tabpanel") implies that tabs are always visible and one tab is always active. That does not describe this UI: the panel can be closed entirely, the cards are not mutually exclusive in all contexts, and the question dock is a separate interaction layer. Buttons with aria-expanded and aria-controls describe what this UI actually does: each card independently controls a shared detail panel.

When ARIA patterns do not fit, use the attributes that describe the behavior. That is what they are for.