Skip to content
Development

Keep a chat box scrolled to the bottom without a single line of scroll code

Anyone who has built a chat window, a log viewer or a terminal on the web knows the routine: every time a message arrives, you call scrollTop = scrollHeight or scrollIntoView() to pull the box to the bottom. There is a tidier way: let the browser itself keep the box at the bottom, with its built-in scroll anchoring.

The whole trick

Turn scroll anchoring off for everything inside the log box, then put a 1px pin at the end as the only anchor:

.log * {
  overflow-anchor: none;
}

.log .pin {
  overflow-anchor: auto;
  height: 1px;
}

In the HTML, the pin is just an empty <div class="pin"></div> at the end of the .log box. New messages are always inserted right before it.

Why it works

Scroll anchoring is the browser's guard against content jumping. When content changes size, the browser picks a visible element as the anchor and adjusts the scroll position so that the anchor stays still on screen. The overflow-anchor property decides which elements may be picked: auto may, none is skipped.

The rule .log * { overflow-anchor: none } takes every message off the list of candidates. The more specific .log .pin rule wins back auto for exactly one element: the pin at the end. When a new message is inserted before the pin, the pin is pushed down. The browser sees its anchor move and scrolls by exactly the new message's height, so the box stays at the bottom.

For each new message, JavaScript only adds it to the DOM and never touches the scroll position.

Example 1: A minimal chat box

Type a few messages and press Send. Your message slides up, the bot shows a “typing” indicator and then replies. Everything is inserted with insertBefore(…, pin); no scroll call runs after a message. The animations change only opacity and transform, so they never disturb the height that anchoring relies on.

Example 1. A chat box with animated new messages and a “typing” indicator.

Example 2: With and without the trick

Press Start to pour messages into both boxes. The left box stays put and new messages overflow out of view. The right box sticks to the bottom on its own.

Example 2. Left: a plain box. Right: a box using .log and .pin.

Example 3: Scrolling up to read back

When you scroll up partway, the pin is out of view, so no anchor is chosen and the box does not yank you back to the bottom. Scroll back down, the pin comes into view, and the box follows new lines again. New lines flash briefly so they are easy to spot.

Example 3. A running log; new lines flash briefly.

Things to watch

  • Always insert new messages before .pin, for example log.insertBefore(msg, pin). With appendChild the message lands after the pin and the trick stops working.
  • .log must be a scroll container: a fixed height and overflow-y: auto.
  • Do not put overflow-anchor: none on .log itself. The .log * selector applies only to its descendants.
  • If you add animations, animate only opacity and transform. Animating height or margin changes the height gradually and the box jerks as it follows.

It needs priming once. In Chrome, scroll anchoring does nothing while the box is at scroll position 0, for example when it starts empty and only later overflows. So the examples prefill a few messages and set scrollTop = scrollHeight exactly once when the box first gets its size (through ResizeObserver). After that, CSS takes over.

Browser support. The trick relies entirely on overflow-anchor. Chromium (Chrome, Edge) and Firefox support it. Safari has long lacked it, so check caniuse.com/css-overflow-anchor before using it in production, and consider keeping a JS scroll fallback for Safari.