The chat shell
The preview above is that screen. The log scrolls. The box stays under it. A grid does that without pinning anything.
.chat {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
height: 100dvh;
}
.log {
overflow-y: auto;
overscroll-behavior: contain;
overflow-anchor: auto;
}minmax(0, 1fr) lets the log shrink inside the grid and scroll, instead of growing the page. The message box is the second row, so it sits under the log for as long as the chat is on screen. overscroll-behavior: contain stops a flick at the end of the log from scrolling the page behind it.
Keep the log from jumping
Loading older messages used to record scrollHeight, prepend the nodes, then add the difference to scrollTop. Miss the timing and the line someone was reading slides away.
overflow-anchor: auto makes that message an anchor. When layout above it changes, the browser moves scrollTop by the same amount in that layout pass. auto is the initial value. Chrome has done this since version 56, Firefox since 66, and Edge since 79. Safari 27 and iOS Safari 27 joined in September 2026, which is when MDN marked it Baseline Newly available. The caniuse figure is about 80%.
Two things turn it off. A rule that sets overflow-anchor: none, and rows that are position: absolute. A virtualized list does the second one, so the browser has nothing in normal flow to anchor.
Grow the message box
The box that grows as someone types used to reset its height to auto on every input event, then set it to scrollHeight. field-sizing: content does that on the textarea.
textarea {
field-sizing: content;
min-height: 3lh;
max-height: 8lh;
overflow-y: auto;
}3lh is three lines. Past 8lh the box scrolls on its own instead of pushing the log off the screen.
Open a tool row
A reasoning block or a tool call is a disclosure. Opening it used to mean reading scrollHeight and animating a fixed max-height. A details element with ::details-content and interpolate-size animates the real height.
<details>
<summary>Checked the calendar</summary>
<p>No overlap on Thursday.</p>
</details>Several tool rows that should only keep one open can share a name, the same way radios do. That pattern is exclusive accordions.
Mark a citation
Highlighting a phrase inside a reply used to mean rewriting the message HTML and wrapping the words in a mark. That breaks the text node the stream is still writing into. The CSS Custom Highlight API paints a range with ::highlight() and leaves the DOM alone.
The range itself is still created in JavaScript. The win is that the message markup does not change while the highlight appears.
Skip work on old messages
A long thread does not need every off-screen message painted. content-visibility: auto skips that rendering until the message is near the viewport. contain-intrinsic-size holds a placeholder size so the scrollbar does not collapse.
Following the latest message
Anchoring holds a message that is already on screen. It does not chase the bottom. When the newest bubble is growing and the reader is already at the end, the log still needs one assignment: scrollTop = scrollHeight. Gate it on that pinned state. If they have scrolled up to read, leave the log alone and let anchoring handle anything inserted above them.
A jump-to-latest control can be ::scroll-button(down) on the log. Chrome and Edge ship it. Firefox and Safari do not yet. The carousel article shows the pseudo-element. Until it is everywhere, a button that sets scrollTop is the fallback.
A model menu next to the box is a separate piece. The popover attribute opens and dismisses it, including Escape and a click outside, without a toggle script.
