Message Scroller
The scroll container of a chat transcript — the part that has to survive streamed replies, restored threads and history loading in above.
It opened at the last turn rather than at the bottom, with the row before it peeking above. Scroll it: the two controls fade in and out as there becomes something to scroll to in that direction.
Installation
uvx django_shadcn@latest add message_scroller
How the transcript grows
This is the decision the whole component turns on, so it comes before the markup.
Upstream is driven by React state: the parent re-renders with new messages and the provider reacts. A Django transcript has no such moment — it grows in the DOM, from htmx, from an event stream, or from a socket handler.
So the provider watches the content element and reacts to whatever put a row there. Anything that inserts a row is enough. You write no JavaScript.
<c-message-scroller.content
hx-get="{% url 'messages' %}"
hx-trigger="every 2s"
hx-swap="beforeend">
{% for message in messages %}
<c-message-scroller.item message_id="{{ message.id }}">
...
</c-message-scroller.item>
{% endfor %}
</c-message-scroller.content>
Append with beforeend and the transcript follows the stream. Insert with
afterbegin and the row the reader is on stays exactly where it is while
history loads above. The same holds for an EventSource handler appending a
row, or a channels consumer doing it — none of them has to tell the component
anything.
Both buttons only insert an element. Append at the bottom and the transcript follows; scroll up first and it does not. Prepend at any scroll position and the row you were reading does not move.
Usage
<c-message-scroller.provider default_scroll_position="last-anchor">
<c-message-scroller class="h-[600px]">
<c-message-scroller.viewport class="p-4">
<c-message-scroller.content>
{% for message in messages %}
<c-message-scroller.item
message_id="{{ message.id }}"
scroll_anchor="{{ message.starts_a_turn|yesno:'true,false' }}">
<c-message align="{{ message.align }}">
<c-message.content>
<c-bubble variant="muted">
<c-bubble.content>{{ message.text }}</c-bubble.content>
</c-bubble>
</c-message.content>
</c-message>
</c-message-scroller.item>
{% endfor %}
</c-message-scroller.content>
</c-message-scroller.viewport>
<c-message-scroller.button direction="start" />
<c-message-scroller.button direction="end" />
</c-message-scroller>
</c-message-scroller.provider>
The provider holds the state and the frame holds the layout, which is why they are two tags: a control placed inside the provider but outside the frame still reaches the scroll methods.
Identifying a row
message_id is required, and it should be the id the server already knows.
It is what anchoring, jumping and visibility tracking all key on — an index
would stop matching the moment older messages load in above.
scroll_anchor="true" marks a row as the start of a turn. Only anchors are
candidates for the opening position and for the reported anchor.
Opening position
default_scroll_position on the provider takes:
| Value | Opens at |
|---|---|
end |
the live edge, following the stream. The default |
start |
the top of the transcript |
last-anchor |
the last turn, near the top, with the previous row peeking above it |
previous_item_peek is how many pixels of the previous row stay visible above
the anchor. It defaults to 48.
Only end engages the follow-the-stream behaviour. Opening part-way up is a
reading position, and following the stream would throw the reader out of it.
The same transcript, opened three ways:
start
end
last-anchor
Following the stream
With auto_scroll on — the default — reaching the live edge engages
stick-to-bottom, and new rows keep the newest in view. Scrolling away releases
it, and the transcript stops moving under the reader. Coming back to the bottom
engages it again.
auto_scroll="false" turns it off entirely.
Preserving the reader's place
preserve_on_prepend is on by default. The provider keeps a fixed point — the
row the reader is on and where it sits — and restores it after anything changes
the content. That covers history arriving above, and it also covers a row above
growing taller, which is what a streamed reply does.
Scrolling from your own controls
The provider exposes three methods to anything inside it:
<button type="button" @click="scrollToMessage('m-42')">Jump to that message</button>
<button type="button" @click="scrollToEnd()">Latest</button>
<button type="button" @click="scrollToStart()">Beginning</button>
scrollToMessage returns false when the id is not on the page, so a link to
a message that has not loaded yet can fall back to a full page load.
anchorId and visibleIds are readable the same way, for a header that names
the turn being read.
Reading the state from CSS
The viewport mirrors its state, so a page can style against the scroll position without touching the Alpine scope:
| Attribute | Meaning |
|---|---|
data-at-start |
the transcript is scrolled to the top |
data-at-end |
the transcript is at the live edge |
data-stick |
new rows are being followed |
data-autoscrolling |
a programmatic scroll is running |
The scroll buttons use the same idea: each carries data-active, and the one
with nothing to scroll to fades out and stops taking clicks rather than
disappearing, so it keeps its place in the tab order.
Accessibility
The viewport is a region with a label, and the content is a log with
aria-relevant="additions" — a new message is announced as it arrives, without
the whole transcript being read back each time. Change the region's name with
label on the viewport when a page has more than one transcript.
A note on what is not here
Upstream sets content-visibility: auto and contain-intrinsic-size on each
row to skip painting what is off screen. Those change what the provider
measures, and a measurement error here is a scroll jump — which is the one
defect this component exists to prevent. They belong after the behaviour has
proven itself, not before.