Resolved Selectors vs. Accessible Names: Why an Accessible Name Isn't an Executable Locator

8

min read

Tutorials

An accessible name tells you what an element is called. A resolved selector tells you how to click it. Most "agent-ready" tooling only gives you the first one — and then asks an LLM to guess the second.

If you've built anything that hands DOM data to an AI agent, you've probably seen a response shape like this:


json

{ "role": "button", "name": "Add to Bag" }

That's an accessible name paired with an ARIA role. It's exactly what a screen reader gets, and it's genuinely useful — it tells an agent (or a human using assistive tech) what the control is and roughly what it does. What it does not tell you is where that element lives in the DOM, whether the string is stable across renders, or whether calling page.click() against it will actually land on the right node. Those are three different problems, and conflating them is where a lot of "agent-ready DOM" tooling quietly falls over.

What an accessible name actually is

The accessible name is a computed string — the browser walks a fallback chain (aria-labelaria-labelledby → associated <label> → own visible text → placeholder, roughly) and produces one label per interactive element. It's designed for one job: giving assistive technology something to read aloud. It was never designed to be unique, stable, or queryable on its own.

Two buttons with the same visible text — "Remove," on five different line items in a cart — get the identical accessible name. A name computed from slotted light-DOM content inside a web component can change between renders if the slot's projected text changes. A name is also visibility-gated: hidden validation or error text that hasn't been revealed yet is correctly excluded from the computation, which means the same element can resolve to a different name (or no name at all) depending on what's currently rendered. None of this is a bug in the accessible-name spec — it's doing exactly what it's supposed to do. It's just not the same job as "give me something I can click."

What a resolved selector is

A resolved selector is an executable locator — a CSS path, an ARIA role+name pair scoped for get_by_role(), or some other query that a browser automation library can hand to a DOM query engine and get back exactly one element, right now, in this render. Building one well means answering questions the accessible-name computation doesn't even ask:

  • Is there a stable id — one that isn't a React useId() fragment, a hashed class blob, or an auto-incrementing digit run — or do we need a positional fallback?

  • Does the element live inside a shadow root? A CSS selector string can't cross a shadow boundary, so an element inside a shadow tree needs a different strategy (role+name via get_by_role, which does pierce shadow DOM) rather than a CSS path that will silently fail to match.

  • If two elements resolve to the same CSS path — a common outcome when a page repeats a card layout — is that selector actually safe to hand to an agent, or does it need to be nulled out so the agent falls back to role+name instead?

A selector-resolution step has to answer all three before it can call an element "clickable." An accessible-name computation answers none of them, because that was never its scope.

Where this breaks in practice

The failure mode isn't exotic — it's the default outcome of skipping the resolution step. An LLM given only {role, name} pairs and a rendered screenshot will frequently produce selector guesses that work in a demo and break the moment the page ships a class-name change, adds a sibling with the same label, or renders a component inside a shadow root. The agent isn't wrong to try; it was handed a description, not an address, and asked to find the building anyway.

This is also why treating "accessible name" and "unique identifier" as interchangeable causes silent collisions rather than loud errors. Five "Yes, this was helpful" buttons across five product reviews all share a name and role. A naive collapse step that keys on (name, role) alone will merge them into one action — quietly dropping four real, distinct buttons — which is a different and worse failure than a selector simply not matching anything.

How _DOM_EXTRACTORS treats these as separate problems

Manifest's extraction pipeline computes both values for every interactive element, but treats them as answers to different questions rather than as one field with two names.

The CSS locator is built through an explicit preference chain: a stable id first (after filtering out React useId() fragments, hash-like id blobs, and long digit runs), then a name attribute — but only if that candidate already resolves to exactly one node in the current render. If neither does, extraction falls back to a positional tag:nth-of-type path, walking up toward <html>, adding :nth-of-type only at levels with same-tag siblings, and stopping the moment the accumulated path is unique — or the moment it can anchor on a stable-id ancestor. There's no depth cap: a path that never resolves uniquely comes back null rather than as a best-effort guess. Elements rooted inside a shadow tree get no CSS locator at all, since a plain CSS string can't cross a shadow boundary. Every candidate is checked against the live DOM before it's emitted, and a second, server-side pass acts as a backstop, nulling any selector that still ends up shared between two resolved actions.

The accessible name is computed separately, roughly following the browser's own fallback order — slotted light-DOM text first, then aria-label, aria-labelledby, an associated <label>, own text, then placeholder — with two deliberate departures. First, only visible text is concatenated in, so hidden validation copy or not-yet-revealed error text doesn't bleed into the label; closed panels correctly yield no name at all rather than one computed from markup that isn't actually reachable yet. Second, as a last resort the chain will credit a <label> that sits next to a control in the DOM but isn't formally associated with it via for/wrapping — a step further than the browser's own computation goes. A name resolved that way is only safe to use through the CSS locator, not through a role+name query, since a real accessibility tree wouldn't credit it either.

Each element in a Manifest action ships with {css, role, name} as three coordinates, not one collapsed string — and the locator strategy for a given element is chosen based on which coordinates are actually trustworthy for it, shadow DOM included. An agent consuming the output gets a resolved way to act on the page, with the accessible name preserved alongside it for context, not standing in for it.

The practical takeaway

If a tool's output for an interactive element is just an accessible-name-and-role pair, it has told you what to call the button, not how to press it. That gap is exactly where agent automation tends to get flaky — not because the LLM reasoned poorly, but because it was asked to solve a DOM-resolution problem with a screen-reader label. Closing that gap is a deterministic, structural problem, not a prompting one.

FAQ

Is an accessible name the same as a CSS selector? No. An accessible name is a computed label string meant for assistive technology; a CSS selector is a query that a browser can execute to find a specific DOM node. Two unrelated elements can share an accessible name and still need two completely different selectors.

Why can't you just click an element by its accessible name? You often can, via get_by_role(role, name=...) — but only if that name is unique on the page at that moment. If several elements share a name (a common pattern in review lists, product cards, or repeated form rows), a name-based click is ambiguous and may act on the wrong element.

Can you match an element by role+name inside shadow DOM? Yes — get_by_role()-style role+name queries pierce shadow boundaries, which is why it's the fallback of choice for shadow-hosted elements. A plain CSS selector string cannot cross a shadow root, so it isn't a viable locator there at all.

What happens when two elements resolve to the same CSS selector? Treating that selector as safe would mean an agent's click could land on either element unpredictably. The correct behavior is to null the CSS locator for both and fall back to role+name (or a more specific selector) instead of shipping a false-confidence locator.

Why does hidden text get excluded from the accessible name? Because a screen reader wouldn't announce it either — hidden validation messages, not-yet-revealed error text, and closed-panel content aren't part of what a user (human or agent) can currently perceive. Including it would produce a name that doesn't match the element's actual current state.

omfang logo

Follow us on social media

Contact us

Learn more about omfang