column-reverse and the chat scroll-anchor pattern

· Tags: flexbox, layout

Building a chat UI used to involve a useEffect hook that called scrollIntoView on the last message every render, plus a separate path for auto-stick when the user has scrolled to the bottom. CSS has a one-line version, and it has been there since the first flexbox spec.

The pattern

.messages {
  display: flex;
  flex-direction: column-reverse;
  overflow-y: auto;
}

Render messages in reverse chronological order in the DOM: newest first. column-reverse flips the visual order, so the newest message appears at the bottom. Critically, the scroll position starts pinned to the bottom because that is the layout’s natural origin. New messages prepended to the DOM appear at the bottom and the user’s view stays anchored to whichever message they are currently reading. No JavaScript required.

Why it works

Per the Flexbox spec, column-reverse reverses the main axis only — the visual flow — not the source order. Browsers compute scroll offsets against the visual flow, so the “scroll start” of a column-reverse container is the bottom edge. Adding new items at the source-order start does not change the scroll position because, visually, those items are at the bottom and the scrollbar stays where it is.

This is the same trick Telegram Web, Slack, and Discord all use. The Discord engineering blog wrote about it in their 2018 post on virtualised chat lists; the relevant CSS is two declarations.

The accessibility caveat

Source order matters for keyboard and screen-reader users. column-reverse hits the same WCAG 1.3.2 concern as order: the visual order does not match the DOM order. For chat, this is usually fine because chat messages are read individually and the temporal direction is well-understood. But if you tab through the messages, you tab from newest to oldest, which is jarring. Consider aria-live announcements for new content rather than relying on tab order.

Sticky input field

Pair the messages container with a flex sibling for the input:

.chat { display: flex; flex-direction: column; height: 100vh; }
.messages { flex: 1; display: flex; flex-direction: column-reverse; overflow-y: auto; }
.input { flex: 0 0 auto; }

The outer flex stacks messages above the input. The inner reverse flex anchors scroll to the bottom. Three rules; no JavaScript scroll listeners.

The Firefox-pre-91 footnote

Older Firefox had a bug where column-reverse with overflow: auto showed the wheel scroll backwards on macOS. Fixed in Firefox 91 (August 2021). If your analytics still show notable Firefox 90 and below traffic, test before shipping.

Related

Get FlexFrog on the App Store. Built by Moruk Labs.