> ## Content Index
> Fetch the complete content index at: https://til.codes/llms.txt
> Use this file to discover other available public pages before exploring further.

# Your Pin-to-Bottom Hook Is Missing the Users Who Need It Most
- URL: https://til.codes/your-pin-to-bottom-hook-is-missing-the-users-who-need-it-most/
- Published: 2026-04-22T08:17:11.000Z
- Updated: 2026-04-22T08:17:11.000Z
- Description: The instinct when building a pin-to-bottom scroller is to listen for `window.resize`. Use ResizeObserver instead, see why below
- Author: Manu Ajith
- Tags: best-practices

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:

```javascript
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:

```javascript
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:

```javascript
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()`.

```javascript
// 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:

```javascript
// 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:

```javascript
// 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:

```javascript
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](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver?ref=til.codes)
- [ResizeObserverEntry on MDN](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserverEntry?ref=til.codes) (covers `contentBoxSize`, `borderBoxSize`, `devicePixelContentBoxSize`)
- [Resize Observer W3C specification](https://www.w3.org/TR/resize-observer/?ref=til.codes) (authoritative on loop-error behavior)
- [WCAG 1.4.4: Resize Text](https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html?ref=til.codes)
- [Firefox: Font size and zoom, including Zoom Text Only](https://support.mozilla.org/en-US/kb/font-size-and-zoom-increase-size-of-web-pages?ref=til.codes)
- [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver?ref=til.codes) and [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver?ref=til.codes) (sibling APIs in the Observer family)