When a client says "this button looks off", the note is only useful if it stays on that button. On a live page that is harder than it sounds. The next deploy renames the CSS classes, a list re-renders in a different order, a hero gets taller on a phone, and a note that was saved as "the element at this path" quietly ends up on something else, or on nothing. Below is the approach I use for comments that clients pin straight onto a staging or production site: what gets stored for each pin, the order in which a pin is looked up again, and what happens when every lookup fails.
The naive version and how it broke
The first version stored one CSS selector per comment, built by walking up from the clicked element. It broke in four ways, roughly in this order:
- Hashed class names.
.Hero_title__x7f2ais gone after the next build. Any selector built from classes dies with it. - The pin sat in the wrong place. I drew the marker at the element's top-left corner. On a full-width hero that is half a screen away from what the client actually tapped.
- Same path, different thing. After a list re-renders,
li:nth-of-type(3)still matches, but it is now another row. The selector "works" and points at the wrong item. - State classes. Page scripts toggle classes like
is-activeoropenon scroll and hover. One of those baked into the path, and the selector misses after a reload. On one site the pin jumped 5838 px down the page to a different button with the same label.
So one selector is one guess. The fix was to store several independent guesses and try them in a fixed order.
What gets stored per pin
{ v: 2, fxid: null, // nearest [data-fx-id] ancestor, if the site has one selector: '', // CSS path, built with the rules below frag: null, // { tag, text }: tag + first 60 chars of visible text struct: '', // the same path with no classes at all rel: { x, y }, // click point as a fraction of the element's box page: { path, hash } } Each field survives a different kind of change. A rebuild that hashes every class leaves fxid, frag and struct intact. A copy change leaves selector and struct intact. A re-ordered list is caught by frag. None of them alone survives everything.
The CSS path, made less fragile
The selector walks up from the clicked element and stops early whenever it finds something stable. This is trimmed from the real function:
function cssPath(el) { const parts = []; let cur = el, depth = 0; while (cur && cur.nodeType === 1 && cur !== document.body && depth < 12) { let sel = cur.tagName.toLowerCase(); if (cur.id) { parts.unshift('#' + cur.id); return parts.join(' > '); } const attr = cur.hasAttribute('data-testid') ? 'data-testid' : cur.hasAttribute('data-area') ? 'data-area' : null; if (attr) { parts.unshift(`${sel}[${attr}="${cur.getAttribute(attr)}"]`); return parts.join(' > '); } // skip classes that page JS toggles on scroll or hover const cls = [...cur.classList].filter(c => !/^(is-|has-)|^(active|open|show|hidden|selected|current|visible)$/.test(c) ).slice(0, 2); if (cls.length) sel += '.' + cls.join('.'); const same = [...(cur.parentElement?.children || [])].filter(c => c.tagName === cur.tagName); if (same.length > 1) sel += `:nth-of-type(${same.indexOf(cur) + 1})`; parts.unshift(sel); cur = cur.parentElement; depth++; } return parts.join(' > '); } Three things in there matter more than they look. An id ends the walk, because nothing above it adds information. data-testid ends it too: if a codebase already has test ids, they are the most stable hooks it owns, and they survive restyling because tests depend on them. And the state-class filter is the fix for the 5838 px jump.
struct is the same walk with the class step removed: tag names and nth-of-type only, anchored at body >. It is ambiguous on its own, so it is never trusted without a second check.
The text fingerprint
function fragOf(el) { const txt = el.innerText || el.textContent || ''; return { tag: el.tagName.toLowerCase(), text: txt.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 60) }; } function fragScore(el, frag) { const f = fragOf(el); if (f.tag !== frag.tag) return 0; if (!frag.text) return f.text ? 0.3 : 0.6; // both empty: weak but real if (!f.text) return 0; if (f.text === frag.text) return 1; if (f.text.startsWith(frag.text) || frag.text.startsWith(f.text)) return 0.8; if (f.text.includes(frag.text) || frag.text.includes(f.text)) return 0.6; return 0; } Class names churn on every build. The words on the page usually don't. The partial scores are there so that a client fixing a typo in a heading doesn't orphan every comment on it.
Where inside the element
function relPoint(el, clientX, clientY) { const r = el.getBoundingClientRect(); if (!r.width || !r.height) return { x: 0.5, y: 0.5 }; const fx = Math.max(0, Math.min(1, (clientX - r.left) / r.width)); const fy = Math.max(0, Math.min(1, (clientY - r.top) / r.height)); return { x: Math.round(fx * 1000) / 1000, y: Math.round(fy * 1000) / 1000 }; } Storing the click as a fraction instead of pixels is what makes it survive layout changes. If the hero is 1440 px wide on desktop and 390 px on a phone, x: 0.82 still lands on the button near its right edge.
The lookup ladder
When the page loads, each pin is resolved by trying the stored fields in order and stopping at the first one that holds up. Trimmed again; the real version also handles shadow DOM paths and skips the widget's own layers:
function resolveAnchor(a) { let el; // 1. an explicit id the site put there on purpose if (a.fxid) { el = document.querySelector(`[data-fx-id="${CSS.escape(a.fxid)}"]`); if (el) return { el, via: 'fxid' }; } // 2. the CSS path, but only if it still points at the same thing el = document.querySelector(a.selector); if (el) { if (!a.frag || fragScore(el, a.frag) >= 0.6) return { el, via: 'selector' }; const better = fragSearch(a.frag); // the path now hits another row if (better) return { el: better, via: 'frag' }; return { el, via: 'selector' }; } // 3. the class-free path, if its text agrees el = document.querySelector(a.struct); if (el && fragScore(el, a.frag) >= 0.8) return { el, via: 'struct' }; // 4. scan every element with that tag for the text el = fragSearch(a.frag); if (el) return { el, via: 'frag' }; // 5. the class-free path on its own el = document.querySelector(a.struct); if (el) return { el, via: 'struct' }; return { el: null, via: null }; // orphan } fragSearch scans elements of the stored tag (capped at 2000) and only accepts a score of 0.8 or better. The real code wraps every querySelector in a try/catch, because a stored selector can be invalid on a page that changed.
Two decisions in the order took a few rounds to get right:
- The selector is verified, not trusted. A matching selector with a fingerprint below 0.6 means the list was re-rendered and the path now belongs to someone else. That is bug 3 from above.
-
structwith matching text goes before the text scan. A page can have the same label five times ("Choose dates" on every booking card). The text scan takes the first match. The structural path knows which of the five the client meant, so it gets the first try, as long as its text still agrees.
via is kept with the result. It costs nothing and tells you, when a pin lands somewhere odd, which rung put it there.
Orphans are shown, never dropped
If every rung fails, the comment is not deleted and not silently placed somewhere close. It stays in the list of comments for that page, with its text and status, plus one line: "This element is no longer on the page." A note that lost its element is still a note. Showing it at a wrong position is worse than showing it without one, because then nobody notices.
Keeping the marker on the element afterwards
Resolving once at load isn't enough on a live page. Lazy images load, fonts swap, accordions open, sliders autoplay. What keeps the markers in place:
- A
requestAnimationFrameloop that first measures every pin (getBoundingClientRectplus therelfraction), then moves every marker. All reads before all writes, so it doesn't force a layout per pin. - The loop parks after 2 seconds without movement. Scroll, resize and a
ResizeObserveron<body>wake it again. - A 500 ms heartbeat measures while the loop is parked. A GSAP timeline that starts on a delay fires no event at all; the heartbeat notices that a pin moved and restarts the loop.
- A
MutationObserveronchildList. A framework re-render hands back a new element for the same thing, and the old one is detached. That needs a re-resolve, not a re-measure, so mutations trigger the ladder again (throttled to 300 ms) for any pin whose element is no longer connected. - An element with no size (
display: none, a closed accordion) hides its marker until it has a box again.
Shadow DOM needs extra care. parentElement stops at a shadow root, so the path is stored as the chain of host selectors plus the path inside the innermost root, and the observer is attached to that root as well. With a closed shadow root you can't get inside, so the pin falls back to the host element and the rel point still lands within it.
If you control the markup
All of this is for sites where you can't or don't want to touch the HTML. If you can, one attribute removes most of the guessing:
<section data-fx-id="pricing"> <button data-testid="pricing-cta">Start free trial</button> </section> A data-fx-id on a section survives any rebuild. A data-testid that your tests already use is the next best thing. Of everything the ladder can find, these are the only anchors that a redesign doesn't move.
If you have found something more reliable for apps that have neither, I'd like to hear about it in the comments.
I build ClickAndSay, which uses this: https://clickandsay.com/en/?src=devto
