Your Pin-to-Bottom Hook Is Missing the Users Who Need It Most
The instinct when building a pin-to-bottom scroller is to listen for `window.resize`. Use ResizeObserver instead, see why below
If you're building a chat view, a log viewer, a live feed, or anything else that needs to stay pinned to the latest content, you'll eventually write a handler that recomputes scroll position when the layout changes. The instinct is to listen for the browser's resize event. That instinct is usually wrong, and the users it fails are the ones who need the feature to work most.
Here's how to recognize the problem, and what to use instead.
The Default Approach
The code you might start with looks like this:
const { signal } = this.abortController
window.addEventListener("resize", this.handlers.resize, { signal })
this.elements.container.addEventListener("scroll", this.handlers.scroll, { signal })
this._scrollToInitialPosition()
It handles the obvious cases. Drag the browser window larger, the handler fires and the view stays pinned. Rotate a tablet, same thing. Open DevTools, covered. This is what most people test, and it passes.
But the viewport and the element you're trying to pin are not the same thing. The resize event fires for the former, not the latter. Your scroller is blind to almost every reason a layout actually changes.
What window.resize Doesn't See
- A user with low vision bumping browser zoom to 200%.
- Firefox's "Zoom Text Only" mode, where layout reflows without the
window reporting any new size. - A textarea auto-grows as the user types a long message. Less room
for content above, same window dimensions. - A side panel or sidebar toggles open. The container got narrower;
the window didn't move. - Images, attachments, or late-loading content pushing earlier content
down after initial layout. - A soft keyboard appearing on mobile. Some browsers fire
resize,
many don't.
Some of these are polish. Several are accessibility failures. The irony is that these are the events most likely to go unnoticed by a developer working at 100% zoom on a laptop. The users most likely to notice a pin drifting out of sync are the ones who rely on larger text, higher zoom, and wider layouts.
The Right Primitive
The fix is ResizeObserver, bound to the element you actually want to
keep pinned:
const { signal } = this.abortController
this.resizeObserver = new ResizeObserver(() => {
this.handlers.resize()
})
this.resizeObserver.observe(this.elements.container)
this.elements.container.addEventListener("scroll", this.handlers.scroll, { signal })
ResizeObserver doesn't care why the element resized. Browser zoom, text-only zoom, a flex sibling taking more space, a new font loading, a panel opening: if the observed element's box changes, the callback runs.
The shift in mental model is subtle but important. Stop asking "did the viewport change?" and start asking "did the thing I'm trying to keep pinned change shape?" The second question is almost always the one you want.
How the Observer Actually Works
Switching to ResizeObserver means understanding a few details that differ from resize events, especially if you're leaving it running on a critical UI element.
The callback runs between layout and paint. The event loop has a dedicated phase for resize observer notifications, after layout has settled but before anything gets painted. You can read layout geometry inside the callback without forcing a synchronous reflow, because the layout is already current. Writes that only affect paint, like scrollTop, happen in the same frame.
It receives entries, not just a signal. Each call gets an array of ResizeObserverEntry objects, one per observed element that changed. The entry carries the new dimensions, so you rarely need to query the DOM again:
this.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { blockSize } = entry.contentBoxSize[0]
this.handlers.resize(blockSize)
}
})
I prefer contentBoxSize and borderBoxSize over the older contentRect. The boxSize APIs are writing-mode aware (blockSize and inlineSize flip for vertical or RTL layouts, so your pin math keeps working in Japanese or Arabic) and they expose the border box directly. If your scroll math depends on padded or bordered geometry instead of content box, pass { box: "border-box" } as the second argument to observe().
contentRect is still useful as a fallback for older browsers (Safari before 15.4). Its width and height are always content-box, so if you need border-box you have no choice but the newer API. One quirk: contentRect.top and contentRect.left are always 0. The rect describes the content box relative to itself, not to the viewport. For on-screen position, use getBoundingClientRect().
// Portable fallback. width/height are always content-box.
new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect
this.handlers.resize(height)
}
})
It fires once immediately after observe(). Unlike window.resize, which only fires on changes, ResizeObserver delivers an initial notification as soon as observation begins. That's your "starting layout" signal for free, and much of the initial-measurement plumbing you might otherwise hand-roll collapses into the same callback.
It batches to one fire per frame. The browser coalesces notifications per animation frame, so you don't need to debounce or wrap the callback in requestAnimationFrame. A flurry of resizes during a CSS transition appears as a single batched call per frame.
It will complain if you cause a loop. If your callback changes the observed element's size, or anything upstream that affects it, the browser skips remaining notifications and logs ResizeObserver loop completed with undelivered notifications. The canonical way to trip it is reading the entry's size and writing it straight back:
// Don't do this.
new ResizeObserver((entries) => {
const entry = entries[0]
entry.target.style.height = `${entry.contentRect.height + 10}px`
}).observe(container)
First notification: height is H. Callback sets it to H + 10. Browser relays out, fires callback with H + 10. Callback sets H + 20. The browser gives up and logs the error.
Scroll pinning dodges this because scrollTop is paint-only; it doesn't affect layout:
// Safe: scrollTop does not resize the element.
new ResizeObserver(() => {
if (isPinned) {
container.scrollTop = container.scrollHeight
}
}).observe(container)
Problems start when you do something more ambitious in the callback: toggling a "jump to latest" badge's visibility, swapping classes on a flex sibling, and resizing the composer. Any of those can round-trip into the observed box. If you need that kind of work, defer layout mutations behind requestAnimationFrame so they land in the next frame, or observe a different element (often the parent) whose size stays stable.
Don't forget to Clean Up
ResizeObserver doesn't accept AbortSignal, so you can't hand it to the AbortController that cleans up your scroll listener. It needs explicit teardown. Observing one element? Use disconnect(). Observing several and want to stop watching one? Use unobserve(target).
In a LiveView hook:
destroyed() {
this.resizeObserver?.disconnect()
this.abortController?.abort()
},
Skip this, and you leak observers across navigations. In a long session, that adds up to real memory and callbacks firing on detached nodes.
The Accessibility Angle
The deeper reason to reach for ResizeObserver isn't just that it catches more cases. It's that window.resize encodes the assumption that layout changes only when the window does, which was already wrong in 2016 and is completely false today. Users reshape their own layouts all the time: zoom, OS font preferences, reader mode, extensions, translation widgets, dynamic content, virtual keyboards.
An accessible UI responds to what the user actually changed, not just to the coarse event the browser fires for a window drag. If any feature you build has "stay pinned," "recompute on resize," or "reflow when the layout changes," the default tool should be ResizeObserver on the element in question. window.resize is a fallback for the rare cases where the window itself genuinely matters.
Further reading:
- ResizeObserver on MDN
- ResizeObserverEntry on MDN (covers
contentBoxSize,borderBoxSize,devicePixelContentBoxSize) - Resize Observer W3C specification (authoritative on loop-error behavior)
- WCAG 1.4.4: Resize Text
- Firefox: Font size and zoom, including Zoom Text Only
- MutationObserver and IntersectionObserver (sibling APIs in the Observer family)