Usage
label that names the conversation, and a jumpToLatestLabel. It renders one Scrollable and nothing else scrolls, so a consumer such as MessageList never implements pin-to-bottom itself.
Button with a downward arrow. When unreadCount is above zero, pass jumpUnreadLabel so assistive technology hears the count; nothing is shown visually. Activating it scrolls to the latest message, smoothly unless the reader prefers reduced motion. The control lives inside the ChatScroll box, so put the composer outside it, below, and the control can never cover it.
unreadCount is a number you own. Count messages that arrive while onAtEndChange last reported false, and reset the count when it reports true. ChatScroll never counts children, because only you know which of them are messages.
A short thread sits at the bottom of the frame, and the control never shows while the content fits.
onReachStart is called when the oldest loaded message scrolls into view. Render a loadingRow while the page loads and an errorRow if it fails. Both appear at the top of the transcript and pause onReachStart while they show. Remove the row in the same update that adds the older messages: the reader's place does not move, whatever the size of the page, and the log stays silent for assistive technology until the row has gone.
Older messages are added above the viewport. ChatScroll measures an anchor message after every commit, scroll and resize and corrects the offset by how far it moved, so images that load above the viewport and a page that arrives at the very start both leave the reader where they were. Native scroll anchoring is off inside the list. Avoid position: sticky among the messages' direct children, because a sticky element moves with the scroll and misleads the anchor.
Children are plain elements in one list. Wrapping messages in groups (one element per day or per sender) is fine. The list has one rhythm and no density option: a --space-24 gutter and a 20px gap between children, added to the children's own spacing. In a MessageList that puts items 28px apart within a sender and 44px apart at a change of sender or around a system line (its 8px and 24px margins plus the gap; the values are owner-confirm 12 in the contract).
Pass the composer as overlay. It takes its own row at the block end, below the transcript, with a fade from the page surface reaching up over the transcript's last few pixels. The transcript ends where the composer starts, so the latest message is never covered, even in the server's markup and while the composer grows, and the jump control sits above it. Nothing is measured. The overlay is outside the log, so it is not announced as transcript. Use a docked composer beside ChatScroll instead when the composer should not overlap messages.
ref, which is the log element, or call back from your composer. It does not count unread messages and it does not fetch history.