
I was building a browser extension for ChatGPT and hit a wall. I needed to scroll to a specific message in the chat, so I did what any developer would do:
window.scrollY
Zero. Every single time.
I tried the classic formula:
element.getBoundingClientRect().top + window.scrollY
Still broken. My coordinates were garbage, and my extension was useless.
Modern SPAs like ChatGPT don't scroll the page. The <body> has a fixed height of 100%. Scrolling happens inside a nested container somewhere in the DOM tree. So window.scrollY sits at 0 forever, and your calculations fail silently.
I spent way too long debugging this. The real answer is simpler than I thought.
The page doesn't scroll. Something inside the page does. You just need to find it.
Here's the approach that worked for me. First, grab the element you care about:
const el = document.querySelector("div.whitespace-pre-wrap");
Then walk up the DOM tree until you find the parent that actually handles the scrolling:
function findScrollParent(element) {
let parent = element.parentElement;
while (parent) {
const style = getComputedStyle(parent);
if (/(auto|scroll)/.test(style.overflowY)) return parent;
parent = parent.parentElement;
}
return document.documentElement;
}
const container = findScrollParent(el);
This function checks each parent's computed overflowY style. The first one with auto or scroll is your scroll container. In ChatGPT, that's usually a <div> with a class like .overflow-y-auto or something similar.
Once you have the container, calculating the real Y position is straightforward:
const y = el.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop;
You're subtracting the container's offset from the element's position and adding back how far the container has scrolled. That gives you the element's position relative to the container's top, which is what you actually wanted in the first place.
Draw a red line at your calculated Y to make sure it lines up perfectly:
const marker = document.createElement("div");
marker.style.cssText = `position: absolute; left: 0; right: 0; background: red; height: 2px; top: ${y}px`;
document.body.appendChild(marker);
If the line sits right on top of your target element, you got it right.
It's not an accident. Frameworks like React and libraries like Tailwind encourage full-height layouts. The pattern is simple: the root element takes the full viewport, and each section manages its own overflow.
Think about it. If the whole page scrolls, then navigation bars, sidebars, and modals fight against that scroll. A sidebar scrolls away when you scroll down. A sticky header needs extra CSS hacks to stay put. By nesting scroll containers, each section of the UI stays independent.
ChatGPT takes this to an extreme. The sidebar scrolls independently. The chat pane scrolls independently. The settings panel scrolls independently. It's a reasonable design choice, but it breaks assumptions that have held since the early days of the web.
You'll hit this same problem in GitHub, Discord, Linear, Notion, and pretty much any React-based SPA. Once you know the pattern, you'll start seeing it everywhere.
I ran into this while building a browser extension. But there are other cases where you might need accurate Y coordinates in an SPA.
UI automation tools like Playwright and Cypress often need to scroll to elements before interacting with them. If their scroll detection uses window.scrollY, they'll fail in the same way.
Custom tooltip or popover positioning breaks when you calculate positions relative to the wrong container. I've seen dropdowns render 2000 pixels above where they should be.
Scroll spy logic for highlighting the current section in view. You need the real container to know what's actually visible.
Screenshot tools that capture full-page content. If the tool doesn't know about nested scroll containers, you'll get a screenshot of whatever fits in the viewport and nothing more.
Here's a quick check to know if you're dealing with this problem:
// If this returns 0, you've got a nested scroll container
console.log("scrollY:", window.scrollY, "body height:", document.body.scrollHeight);
Why does window.scrollY return 0 in apps like ChatGPT?
What's the correct formula for an element's Y position in a nested scroll container?
ChatGPT is a streaming app. New messages appear while you're looking at the page. Elements get added, removed, and shifted around.
If you're tracking a specific element's position over time, don't calculate it once and cache the value. Recalculate on scroll and on DOM changes. A MutationObserver paired with a scroll event listener covers both cases:
const observer = new MutationObserver(() => {
const y = el.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop;
// do something with the updated y
});
observer.observe(container, { childList: true, subtree: true });
A ResizeObserver on the container is also useful if the layout shifts. I've found that combining both observers with a throttle on the scroll handler covers every edge case I've encountered.
This matters more than you think. ChatGPT loads messages lazily as you scroll up. When new content loads above your tracked element, its position changes. Without the observer, you'd be pointing at stale coordinates.
MutationObserver fires a lot. If you're measuring positions inside the callback, throttle it. Otherwise you'll tank the UI's scroll performance, and nothing ruins a demo like stuttering scroll.
Three things to remember:
window.scrollY only works when the document itself scrolls. In SPAs, that's usually not the case.overflowY.getBoundingClientRect().top - container.getBoundingClientRect().top + container.scrollTop.That's it. No magic. No framework tricks. Just understanding how the DOM actually works under modern SPAs. Once I stopped assuming window was the scroll target, everything clicked into place.
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

A developer's honest take on ChatGPT, Claude, and Gemini: where each one helps, where each one gets in the way, and how to pick the right tool for the task.
Read more
A deep dive into managing forms in Next.js 14 using Server Actions, useFormState, useFormStatus, and Zod for schema validation. Learn to build modern, progressive-enhanced forms without client-side JavaScript fatigue.
Read more
Detailed performance comparison between Intersection Observer and getBoundingClientRect for scroll tracking, lazy loading, and viewport detection.
Read more