Message Scroller

The scroll container of a chat transcript — the part that has to survive streamed replies, restored threads and history loading in above.

Can you look at the registry output?
Reading it now.
There is a stale route in there somewhere.
Found it — the entry was never added.
That explains why the CLI refused the name.
Adding it now, with a test that would have caught it.
The test is the part that matters. Where does it go?
Next to the one that reads the registry, so they fail together.
Good. Push it and I will look.
Pushed.

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.

Watch what happens when a row arrives.
Scroll up first, and it will leave you alone.
Come back to the bottom and it follows again.
The buttons below stand in for your server.
Neither of them tells the component anything.
They only put an element in the DOM, which is all htmx does.
Try it from the bottom, then from halfway up.

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

Row 1
Row 2
Row 3
Row 4 · anchor
Row 5
Row 6

end

Row 1
Row 2
Row 3
Row 4 · anchor
Row 5
Row 6

last-anchor

Row 1
Row 2
Row 3
Row 4 · anchor
Row 5
Row 6

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.