· 4 min read
The tree before the pixels
An accessibility snapshot exposes the headings, labels, form, and button that a screenshot-matched checkout can lose before semantic HTML repairs them.
A checkout can pass every visual review and still hand a screen reader five anonymous lines. In the fixture below, “Checkout” looks like a heading because CSS enlarged it, and “Pay $48” looks like a button because a div got a dark background and an onclick. None of that costume reaches the browser's accessibility tree, which is the page assistive technology actually receives.
<div class="checkout-card"> <div class="title">Checkout</div> <div class="summary"> <div>Design system audit</div> <div>$48</div> </div> <div class="field-label">Card number</div> <input placeholder="Card number"> <div class="pay" onclick="submitPayment()">Pay $48</div></div>- text: Checkout- text: Design system audit- text: $48- textbox "Card number"- text: Pay $48Read the second tab the way a screen-reader user meets the page. The textbox has a name only while it is empty, because a placeholder vanishes under typed content. There is no heading to jump to, no form owning the payment controls, no button role, no keyboard path to pay, and no status node where a result could ever be announced. Matching the screenshot concealed every one of those losses, which is the trouble with grading pages by their pixels.
The fixture is rigged, I admit — it exists to isolate the failure. A production checkout buries the same pattern under more markup, where it is harder to spot and no less absent from the tree.
Repair the document, then look again#
The repair starts with plain elements. Browsers derive roles, names, and relationships from the DOM: the HTML Accessibility API Mappings spell out how each native element enters the tree, and the accessible-name computation decides which visible text names a control. So an h1 restores something to jump to, a section labeled by its h2 becomes the order-summary region, a form named by its heading owns the card field and the submit button, and a native button brings focus and keyboard activation along for free. The label survives typing; the placeholder never did.
- main: - heading "Checkout" [level=1] - region "Order summary": - heading "Order summary" [level=2] - list: - listitem: Design system audit $48 - form "Payment details": - heading "Payment details" [level=2] - textbox "Card number" - button "Pay $48" - status<main class="checkout"> <h1>Checkout</h1> <section class="summary" aria-labelledby="summary-title"> <h2 id="summary-title">Order summary</h2> <ul> <li> <span>Design system audit</span> <strong>$48</strong> </li> </ul> </section> <form class="payment" aria-labelledby="payment-title"> <h2 id="payment-title">Payment details</h2> <label for="card">Card number</label> <input id="card" name="card" inputmode="numeric" autocomplete="cc-number" aria-describedby="card-hint" > <p id="card-hint">Enter the number without spaces.</p> <button type="submit">Pay $48</button> <p role="status"></p> </form></main>The visible card barely moves. Underneath it, the document has grown enough structure to navigate and operate with no pixels at all, which is what the figure's tree is showing.
accessibility tree
main ├─ heading "Checkout" (level 1) ├─ region "Order summary" │ ├─ heading "Order summary" (level 2) │ └─ list │ └─ listitem "Design system audit $48" └─ form "Payment details" ├─ heading "Payment details" (level 2) ├─ textbox "Card number" ├─ button "Pay $48" └─ status
None of this dictates layout. CSS can still place the summary beside the form on a wide screen, provided source order and focus order keep making sense when the columns collapse — WCAG's meaningful-sequence criterion tests that relationship rather than any particular grid technique. Reversed flex rows and absolute positioning are where the visual order and the programmatic one quietly drift apart, so they have earned the suspicion.
Keep the semantic diff executable#
What do you hand the next agent that edits this page, so it stops undoing the repair? Playwright serializes the accessibility tree as order-sensitive YAML and diffs it with ARIA snapshots, which turns “check accessibility” into an artifact that fails loudly after an edit. The contract stays scoped to this checkout instead of freezing an application-wide tree.
await expect(page.getByRole("main")).toMatchAriaSnapshot(` - main: - heading "Checkout" [level=1] - region "Order summary": - heading "Order summary" [level=2] - list: - listitem: Design system audit $48 - form "Payment details": - heading "Payment details" [level=2] - textbox "Card number" - button "Pay $48" - status`);await page.getByRole("button", { name: "Pay $48" }).press("Enter");await expect(page.getByRole("status")).toHaveText(/Processing|approved/);The snapshot catches a button collapsing into generic text, a heading losing its level, a status node disappearing. The keyboard assertion covers behavior the tree cannot prove. One human duty remains: when the snapshot changes on purpose, somebody has to explain why, because regenerating the expected YAML on every red run is a machine for blessing regressions.
Passing this tree is a narrow success, and it should be kept narrow. The serialization ends at status — no boxes, no contrast ratios, no coordinates — so it cannot say whether the price and the button overlap at 200 percent zoom. That check still belongs to eyes.