When I built PinReview, a tool that lets clients click anywhere on a website and leave feedback, I thought the hard part would be screenshots. It wasn't. The hard part was making a pin
still point at the right element after the page changes.
A client pins a comment on a button. The next day a developer adds a banner above it, or opens the page on a phone instead of a laptop. Where should the pin go now?
## Coordinates don't work
My first idea was to save the click position (x, y). It breaks immediately: different screen sizes, scrolling, responsive layouts and any content added above the element all move it.
So instead of saving where the click happened, I save what was clicked.
## A ranked list of selectors
When someone pins an element, I build a list of CSS selectors, from most stable to least stable:
function buildSelectorChain(el) {
const chain = [];
if (el.id && isStableId(el.id)) chain.push(`#${CSS.escape(el.id)}`);
for (const attr of ["data-testid", "data-test", "data-cy", "name", "aria-label"]) {
const v = el.getAttribute(attr);
if (v) { chain.push(`${el.tagName.toLowerCase()}[${attr}="${v}"]`); break; }
}
const classes = [...el.classList].filter((c) => !/^(css-|sc-|jsx-|hover|active|focus)/.test(c));
if (classes.length) chain.push(`${el.tagName.toLowerCase()}.${classes.join(".")}`);
chain.push(nthChildPath(el)); // always present, last resort
return chain;
}
A few details turned out to matter:
-
Auto-generated IDs are a trap. IDs like
:r1a:(React), UUIDs orember123change on every build, soisStableIdrejects anything that looks generated. -
CSS-in-JS class names are noise.
css-1x2y3zorsc-abc123change between deploys, so they're filtered out. -
Test attributes are gold.
data-testidrarely changes, which makes it the best anchor after a real ID.
To find the element later, I try each selector in order and take the first one that matches exactly one element:
function resolveSelectorChain(chain) {
for (const sel of chain) {
const matches = document.querySelectorAll(sel);
if (matches.length === 1) return matches[0];
}
return null;
}
## Position relative to the element
The pin's position is stored as a percentage of the element's size, not of the page. A pin at 30% across a button stays at 30% across that button, whether the button is 200px wide on
desktop or 120px on mobile.
## Keep the original evidence
Every comment also stores a screenshot of what the reviewer actually saw, with the pin drawn on it, plus the page URL, viewport size, browser, OS and console errors. Even if the live page
changes completely, the developer can always see the original context.
## Where it still breaks
The honest part: lists of identical items. Imagine three product cards with the same classes and no IDs. They fall back to the DOM-position selector, so if a new card is inserted above,
the pin moves to the wrong card, and nothing warns you.
Today the fix is simple: give repeated items a unique id or data-testid. I'm also looking at storing a small fingerprint of the element (its text and tag) so a pin can say "this may
have moved" instead of confidently pointing at the wrong thing.
## Try it
You can try PinReview live, no sign-up needed: https://pinreview.one/demo
I'm launching on Product Hunt on October 19: https://www.producthunt.com/products/pinreview-2
How would you handle the identical-cards case? I'd love to hear other approaches.
Top comments (1)
Database access patterns and lock contention are where distributed latency bottlenecks usually hide. Great breakdown of the trade-offs here!