CSS Selector Stability in Dynamic UIs
Why Selectors Break
CSS selectors are the most natural way to reference a DOM element. document.querySelector('main > form > button.submit') returns the exact element you mean. But in modern web applications, every part of that selector is unstable.
The class name submit might be submit-a3f7b2 tomorrow — hashed by styled-components or Emotion. The form wrapper might be removed during a refactor. The main container might get an additional div inserted for layout purposes. The button might become an a tag when a designer switches from a button to a link. Each of these changes is routine in active development, and each one breaks the selector.
This is not a theoretical problem. Any tool that anchors feedback to DOM elements — whether for design review, QA, or user bug reports — must solve selector stability or accept that feedback drifts, detaches, and loses its meaning after every deploy. The problem is central to element-anchored feedback and directly causes the context loss described in The Feedback Collapse.
What Causes Breakage
The common thread across all six causes is that selectors depend on implementation details that change between builds. Class names, IDs, parent structure, and even element existence are all mutable in a codebase under active development. A stable anchoring strategy must depend on properties that are either explicitly stable (developer-assigned IDs) or content-derived (what the element says and does, not where it sits in the tree).
Framework Classes
Tailwind's JIT compiler generates utility classes that are stable by convention but combined into class strings that change when markup is edited. CSS-in-JS libraries like styled-components and Emotion generate class names that include content hashes — sc-a3f7b2 — that change whenever the styled component's CSS changes. Selectors that include these classes break on every CSS edit.
Dynamic IDs and Keys
React generates keys for list items that may change on reorder. Auto-generated IDs from UI libraries (Radix, Headless UI, MUI) include incrementing counters or random suffixes. A selector like #radix-\:r4\: is meaningless after a re-render.
SPA Navigation
Single-page applications replace entire DOM subtrees on route changes. React Router, Next.js App Router, and similar frameworks unmount and remount components during navigation. A selector that was valid on /checkout points to nothing after navigating to /account and back, because the elements have been recreated.
The Resolution Strategy
Lay solves selector instability with a four-stage resolution pipeline. Each stage is tried in order; the first successful match wins.
Stage 1: data-feedback-id
The highest-priority anchor is an explicit, developer-assigned identifier: data-feedback-id="checkout-submit". This is a contract between the developer and the anchoring system. It survives any amount of refactoring — class name changes, parent restructuring, tag changes — because the developer has declared this element's identity explicitly.
This is optional. Most elements do not need it. But for critical UI elements where feedback must survive major rewrites — primary CTAs, navigation items, key form fields — a data-feedback-id provides absolute stability.
Stage 2: CSS Selector
When no explicit ID exists, the system generates a CSS selector at capture time. The generation algorithm prefers stable attributes over unstable ones:
idattributes that appear hand-written (not auto-generated)data-testid,data-cy, and other test attributesroleandaria-labelfor semantic elements- Tag name and nth-child position as a structural fallback
- Class names are used only when they appear stable (no hashes, no random suffixes)
The algorithm produces the shortest selector that uniquely identifies the element. Shorter selectors are more resilient — button[data-testid="submit"] survives more refactoring than div.container > div.wrapper > form > div:nth-child(3) > button.sc-a3f7b2.
Stage 3: Element Fingerprint
If the CSS selector fails — the DOM has changed enough that querySelector returns null — the system falls back to fingerprinting. At capture time, a fingerprint is recorded: the element's tag name, a hash of its visible text content, key attributes (role, type, name, aria-label, placeholder), and its approximate document position (percentage-based).
On resolution, the system scans all elements on the page and scores each against the stored fingerprint. The highest-scoring match above a confidence threshold is accepted. This approach survives structural changes (added/removed parent containers), class name changes, and even tag changes — as long as the element's content and role remain recognizable.
Stage 4: Reattach or Orphan
If fingerprinting also fails, the system falls back to viewport-relative coordinates captured at feedback time. This is a last resort — coordinates are approximate and drift with layout changes. If no match can be established with acceptable confidence, the feedback is flagged as orphaned. Orphaned feedback remains visible in the dashboard with its original metadata and screenshot, but it is no longer attached to a live element on the page.
Building for Stability
Development teams can improve selector stability proactively:
Add data-feedback-id to critical elements. The checkout button, the navigation menu, the pricing table — any element that accumulates feedback over time. This is a one-time annotation that provides permanent stability.
Use data-testid attributes. Many teams already annotate elements for end-to-end testing. These attributes are stable by design (they exist specifically to be referenced by selectors) and the anchoring system uses them as high-priority signals during selector generation.
Avoid CSS-in-JS class names as identifiers. Do not reference hashed class names in test selectors, feedback anchors, or automation scripts. They are implementation artifacts, not stable identifiers.
Prefer semantic HTML. A <button> with an aria-label is more uniquely identifiable than a <div> with a click handler. Semantic elements carry more attributes that the fingerprinting algorithm can use for matching.
Measurement
Teams can measure selector stability by tracking the reattachment rate across deploys. After each deployment, the anchoring system resolves all active feedback against the new DOM. The resolution report shows:
- Direct match rate — percentage of feedback resolved by CSS selector on the first attempt
- Fingerprint fallback rate — percentage requiring fingerprint matching
- Orphan rate — percentage that could not be resolved at all
A healthy codebase with stable test attributes and semantic HTML typically shows 85-95% direct match rates. Codebases with heavy CSS-in-JS and no test attributes may see 60-70%, with the fingerprint layer recovering most of the remainder. The orphan rate should stay below 5% — and the orphaned items are typically elements that were intentionally removed.