View as Markdown

Message Scroller

A chat scroll container that anchors turns, opens saved transcripts, follows streamed responses, loads history without jumping, and jumps to any message.

100%
Loading...
<twig:MessageScroller:Provider>
    <twig:Card class="">
        <twig:Card:Header class="">
            <twig:Card:Title>New Chat</twig:Card:Title>
            <twig:Card:Description>How can I help you today?</twig:Card:Description>
        </twig:Card:Header>
        <twig:Card:Content class="">
            <twig:MessageScroller>
                <twig:MessageScroller:Viewport>
                    <twig:MessageScroller:Content class="">
                        <twig:MessageScroller:Item messageId="demo-1" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="demo-2">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content class="">
                                            <p class="">That's the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll`: the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.</p>
                                            <p class="">The important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user's intent.</p>
                                        </twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="demo-3" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>Okay, but when someone sends a new message the view still feels jarring, like the whole conversation reloads from the top.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="demo-4">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content class="">
                                            <p class="">`MessageScroller:Item` fixes that with turn anchoring. Set `scrollAnchor` on the turn that should settle near the top instead of blindly snapping to the document bottom.</p>
                                            <p class="">It also leaves a small peek of the previous exchange visible above the anchor, so context isn't lost. The reply starts in view without that disorienting jump you get from a plain overflow container.</p>
                                        </twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="demo-5" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>And if they've scrolled up to re-read an older answer? I don't want to yank them back down.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="demo-6">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content class="">
                                            <p class="">You won't. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out: their place in the thread stays put even as new tokens keep arriving below.</p>
                                            <p class="">When there is content they haven't seen yet, `MessageScroller:Button` appears at the bottom of the viewport. One tap jumps them back to the newest message and re-engages auto-scroll. Same pattern as Slack or iMessage: quiet when you're caught up, helpful when you're not.</p>
                                        </twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                    </twig:MessageScroller:Content>
                </twig:MessageScroller:Viewport>
                <twig:MessageScroller:Button />
            </twig:MessageScroller>
        </twig:Card:Content>
        <twig:Card:Footer class="">
            <twig:InputGroup>
                <div class="">
                    <span class="">Last one: does this work with assistive tech?</span>
                </div>
                <twig:InputGroup:Addon align="block-end" class="">
                    <twig:InputGroup:Button variant="outline" size="icon-sm" aria-label="Add files">
                        <twig:ux:icon name="lucide:plus" />
                    </twig:InputGroup:Button>
                    <twig:InputGroup:Button variant="default" size="icon-sm" class="" aria-label="Send">
                        <twig:ux:icon name="lucide:arrow-up" />
                    </twig:InputGroup:Button>
                </twig:InputGroup:Addon>
            </twig:InputGroup>
        </twig:Card:Footer>
    </twig:Card>
</twig:MessageScroller:Provider>

Installation

Note

Available since UX Toolkit 3.7.

php bin/console ux:install message-scroller --kit shadcn

Install the following Composer dependencies:

composer require twig/extra-bundle twig/html-extra:^3.24.0 symfony/ux-icons symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0

Install the following front-end dependencies:

npm install shadcn tw-animate-css
# ...or with Symfony AssetMapper
php bin/console importmap:require tw-animate-css/dist/tw-animate.css shadcn/dist/tailwind.css

Copy the following file(s) into your app:

assets/controllers/message_scroller_controller.js
import { Controller } from '@hotwired/stimulus';

// Two scroll positions within this distance are treated as equal, to absorb zoom and HiDPI rounding.
const SCROLL_POSITION_EPSILON = 0.5;
// How long `data-autoscrolling` stays set after a programmatic scroll to the end.
const AUTOSCROLLING_CLEAR_DELAY = 180;
const USER_SCROLL_KEYS = new Set(['ArrowDown', 'ArrowUp', 'End', 'Home', 'PageDown', 'PageUp', ' ']);

const FOLLOWING_END = 'following-end';
const FREE_SCROLLING = 'free-scrolling';
const ANCHORED = 'anchored';
const SETTLING_JUMP = 'settling-jump';

/**
 * Scroll behavior of a chat transcript: it opens at a chosen position, settles a new turn near the top,
 * follows a streamed reply only while the reader stays at the end, and keeps the reader's place when
 * older rows are added above.
 *
 * @value  autoScroll              Whether the viewport follows new content while it is at the end of the transcript.
 * @value  defaultScrollPosition   Where the transcript opens on the first render: `start`, `end` or `last-anchor`.
 * @value  scrollEdgeThreshold     Distance in pixels from an edge that still counts as being at that edge.
 * @value  scrollPreviousItemPeek  Pixels of the previous item kept visible above a newly anchored item.
 * @value  scrollMargin            Margin in pixels kept on the aligned edge when scrolling to an item.
 * @target root                    The frame, which reflects the scroll state like the viewport.
 * @target viewport                The scrollable element.
 * @target content                 The element holding the rows of the transcript.
 * @target spacer                  The empty row that gives a newly anchored turn room to settle near the top.
 * @target button                  A button scrolling to one edge, active only while there is content in its direction.
 * @target anchorLink              A control calling `scrollToMessage`, marked with `aria-current` while its row is the current turn.
 * @action scrollToStart           Scrolls to the start of the transcript.
 * @action scrollToEnd             Scrolls to the end of the transcript, and follows new content again when `autoScroll` is on.
 * @action scrollToMessage         Scrolls to the row whose `messageId` is the `id` param, aligned with the `align` param.
 */
export default class extends Controller {
    static targets = ['root', 'viewport', 'content', 'spacer', 'button', 'anchorLink'];
    static values = {
        autoScroll: Boolean,
        defaultScrollPosition: { type: String, default: 'end' },
        scrollEdgeThreshold: { type: Number, default: 8 },
        scrollPreviousItemPeek: { type: Number, default: 64 },
        scrollMargin: { type: Number, default: 0 },
    };

    #mode;
    #anchoredItem = null;
    #handledAnchors = new WeakSet();
    #itemCount = 0;
    #firstItem = null;
    #lastScrollTop = 0;
    #spacerHeight = 0;
    #autoscrolling = false;
    #autoscrollingTimeout = null;
    #defaultPositionApplied = false;
    #pendingScrollToMessage = null;
    #prependAnchor = null;
    #stateFrame = null;
    #resizeFrame = null;
    #currentAnchorFrame = null;
    #currentAnchorId;
    #contentObserver = null;
    #resizeObserver = null;

    initialize() {
        this.#mode = this.#restingMode;
    }

    connect() {
        this.#contentObserver = new MutationObserver(() => this.#handleContentChange());

        // Coalesced on a frame: the resize handler changes the spacer inside an observed element,
        // which would otherwise report "ResizeObserver loop completed with undelivered notifications".
        this.#resizeObserver = new ResizeObserver(() => {
            cancelAnimationFrame(this.#resizeFrame);
            this.#resizeFrame = requestAnimationFrame(() => this.#handleResize());
        });
        this.#observe();

        this.#readTranscript();
    }

    disconnect() {
        this.#contentObserver.disconnect();
        this.#resizeObserver.disconnect();
        this.#contentObserver = null;
        cancelAnimationFrame(this.#stateFrame);
        cancelAnimationFrame(this.#resizeFrame);
        cancelAnimationFrame(this.#currentAnchorFrame);
        clearTimeout(this.#autoscrollingTimeout);
        this.#stateFrame = null;
        this.#currentAnchorFrame = null;
        this.#autoscrollingTimeout = null;
        this.#autoscrolling = false;
    }

    // A content or a viewport replaced while connected, like by a Turbo Stream `replace`, holds another transcript:
    // the observers follow the new elements, and the transcript opens again at its default position.
    contentTargetConnected() {
        this.#restartTranscript();
    }

    viewportTargetConnected() {
        this.#restartTranscript();
    }

    anchorLinkTargetConnected() {
        // A new link has no `aria-current` yet: it is written even if the current turn did not change.
        this.#currentAnchorId = undefined;
        this.#scheduleCurrentAnchorSync();
    }

    scrollToStart(event) {
        this.#blurButton(event);
        this.#scrollToStart(event?.params?.behavior ?? 'auto');
    }

    scrollToEnd(event) {
        this.#blurButton(event);
        this.#scrollToEnd(event?.params?.behavior ?? 'auto');
    }

    scrollToMessage({ params: { id, align = 'start', behavior = 'auto' } = {} } = {}) {
        if (undefined === id) {
            return;
        }

        const messageId = String(id);
        const item = this.#findItem(messageId);
        if (!item) {
            // Rows that are not rendered yet, like a transcript loaded after the page, get the jump once they arrive.
            if (0 === this.#itemCount) {
                this.#pendingScrollToMessage = { messageId, align, behavior };
                this.#markDefaultPositionApplied();
            }

            return;
        }

        this.#markDefaultPositionApplied();
        this.#scrollToElement(item, { align, behavior });
    }

    handleScroll() {
        this.#capturePrependAnchor();
        // The scroll event already reports the new position: the commit a programmatic scroll scheduled is not needed.
        cancelAnimationFrame(this.#stateFrame);
        this.#stateFrame = null;
        this.#commitScrollState();
        this.#scheduleCurrentAnchorSync();
    }

    // A deliberate gesture releases the follow, the turn anchoring and a jump in flight, so the reader is never fought.
    handleUserScroll() {
        if (FREE_SCROLLING !== this.#mode) {
            this.#anchoredItem = null;
            this.#mode = FREE_SCROLLING;
        }
    }

    // Checked on the key alone: Shift+Space and the other modified scroll keys release the viewport too.
    handleUserKeydown(event) {
        if (USER_SCROLL_KEYS.has(event.key)) {
            this.handleUserScroll();
        }
    }

    // The mode the viewport returns to after a jump to an edge.
    get #restingMode() {
        return this.autoScrollValue ? FOLLOWING_END : FREE_SCROLLING;
    }

    #observe() {
        this.#contentObserver.disconnect();
        this.#resizeObserver.disconnect();
        this.#contentObserver.observe(this.contentTarget, { childList: true });
        this.#resizeObserver.observe(this.viewportTarget);
        this.#resizeObserver.observe(this.contentTarget);
    }

    #restartTranscript() {
        // Before `connect()`, which reads the first transcript itself, there is nothing to restart.
        if (!this.#contentObserver || !this.hasViewportTarget || !this.hasContentTarget) {
            return;
        }

        this.#observe();
        this.#mode = this.#restingMode;
        this.#anchoredItem = null;
        this.#itemCount = 0;
        this.#firstItem = null;
        this.#lastScrollTop = 0;
        this.#spacerHeight = 0;
        this.#defaultPositionApplied = false;
        this.#pendingScrollToMessage = null;
        this.#prependAnchor = null;
        this.#readTranscript();
    }

    #readTranscript() {
        this.#handleContentChange();
        // An empty transcript has no position to wait for.
        if (0 === this.#itemCount) {
            this.#clearPendingScroll();
        }
    }

    #handleContentChange() {
        this.#keepSpacerLast();

        const items = this.#items();
        const previousItemCount = this.#itemCount;
        const previousFirstItem = this.#firstItem;
        this.#itemCount = items.length;
        this.#firstItem = items[0] ?? null;

        this.#reconcileScrollPosition(items, previousItemCount, previousFirstItem);
        // The anchors rendered at this point are settled: only a row added or replaced later starts a new turn.
        for (const item of items.filter(isScrollAnchor)) {
            this.#handledAnchors.add(item);
        }
        this.#capturePrependAnchor();
    }

    // The order of the cases matters: first content, prepended rows, appended rows, then updated rows.
    #reconcileScrollPosition(items, previousItemCount, previousFirstItem) {
        if (this.#flushPendingScrollToMessage()) {
            return;
        }

        if (0 === previousItemCount) {
            if (this.#applyDefaultScrollPosition()) {
                return;
            }

            if (items.length > 0 && this.autoScrollValue) {
                this.#scrollToEnd('auto');

                return;
            }

            this.#commitScrollState();
            this.#scheduleCurrentAnchorSync();

            return;
        }

        if (this.#preservesScrollOnPrepend() && previousFirstItem && items.indexOf(previousFirstItem) > 0) {
            // Rows added above are not new turns: the reader stays on the row they were looking at.
            this.#restorePrependAnchor();

            return;
        }

        if (items.length > previousItemCount) {
            const newAnchors = items.slice(previousItemCount).filter(isScrollAnchor);
            if (newAnchors.length > 0) {
                // A batch of turns arriving while the reader follows the end keeps following it,
                // instead of jumping back to the first turn of the batch.
                if (newAnchors.length > 1 && this.autoScrollValue && FOLLOWING_END === this.#mode) {
                    this.#scrollToEnd('auto');

                    return;
                }

                this.#anchorTurn(newAnchors[0]);

                return;
            }
        }

        if (items.length === previousItemCount) {
            // A row that becomes an anchor in place, like a reply turned into a new turn, settles like an appended one.
            const anchor = items.find((item) => isScrollAnchor(item) && !this.#handledAnchors.has(item));
            if (anchor) {
                this.#anchorTurn(anchor);

                return;
            }
        }

        if (FOLLOWING_END === this.#mode && this.autoScrollValue) {
            this.#scrollToEnd('auto');

            return;
        }

        this.#commitScrollState();
        this.#scheduleCurrentAnchorSync();
    }

    #handleResize() {
        if (this.#applyDefaultScrollPosition()) {
            return;
        }

        if (FOLLOWING_END === this.#mode && this.autoScrollValue) {
            this.#scrollToEnd('auto');

            return;
        }

        // Hold the anchored turn in place while the content below it changes, like a streamed reply.
        const previousSpacerHeight = this.#spacerHeight;
        if (this.#reanchorTurn()) {
            // Once the reply has used up the spacer, it fills the viewport and the reader is at the live edge.
            // Waiting for that transition keeps a turn taller than the viewport, placed without spacer, held.
            if (this.autoScrollValue && previousSpacerHeight > 0 && 0 === this.#spacerHeight) {
                this.#scrollToEnd('auto');
            }

            return;
        }

        this.#scheduleStateCommit();
        this.#scheduleCurrentAnchorSync();
    }

    #applyDefaultScrollPosition() {
        // A transcript in a hidden panel has no height yet: its position is applied once it is shown.
        if (this.#defaultPositionApplied || 0 === this.#itemCount || 0 === this.viewportTarget.clientHeight) {
            return false;
        }

        if ('start' === this.defaultScrollPositionValue) {
            this.#scrollToStart('auto');
        } else if ('last-anchor' === this.defaultScrollPositionValue) {
            this.#scrollToLastAnchor();
        } else {
            this.#scrollToEnd('auto');
        }

        this.#markDefaultPositionApplied();

        return true;
    }

    #scrollToLastAnchor() {
        const anchor = this.#items().findLast(isScrollAnchor);
        if (!anchor) {
            this.#scrollToEnd('auto');

            return;
        }

        // A last turn that fits below its anchor opens at the end, which shows it whole without a gap below.
        const lastTurnHeight = this.#contentBottom() - elementScrollTop(anchor, this.viewportTarget);
        if (lastTurnHeight <= this.viewportTarget.clientHeight) {
            this.#scrollToEnd('auto');

            return;
        }

        this.#scrollToElement(anchor, { align: 'start' }, { keepPreviousPeek: true });
    }

    #flushPendingScrollToMessage() {
        if (!this.#pendingScrollToMessage) {
            return false;
        }

        const { messageId, align, behavior } = this.#pendingScrollToMessage;
        const item = this.#findItem(messageId);
        if (!item) {
            return false;
        }

        this.#pendingScrollToMessage = null;
        this.#scrollToElement(item, { align, behavior });

        return true;
    }

    #anchorTurn(item) {
        this.#scrollToElement(item, { align: 'start' }, { keepPreviousPeek: true });
    }

    #reanchorTurn() {
        if (ANCHORED !== this.#mode || !this.#anchoredItem?.isConnected) {
            return false;
        }

        // Placing the turn again resizes the spacer for the new content height.
        this.#scrollToElement(this.#anchoredItem, { align: 'start' }, { keepPreviousPeek: true });

        return true;
    }

    #scrollToStart(behavior) {
        this.#setSpacerHeight(0);
        this.#anchoredItem = null;
        this.#mode = FREE_SCROLLING;
        this.#scrollToPosition(0, { behavior });
        this.#scheduleCurrentAnchorSync();
    }

    #scrollToEnd(behavior) {
        const viewport = this.viewportTarget;

        this.#setSpacerHeight(0);
        this.#anchoredItem = null;
        this.#mode = this.#restingMode;
        this.#scrollToPosition(Math.max(0, viewport.scrollHeight - viewport.clientHeight), {
            behavior,
            autoscrolling: true,
        });
        this.#scheduleCurrentAnchorSync();
    }

    #scrollToElement(element, { align = 'start', behavior = 'auto' } = {}, { keepPreviousPeek = false } = {}) {
        const viewport = this.viewportTarget;
        const scrollMargin = this.scrollMarginValue + (keepPreviousPeek ? this.scrollPreviousItemPeekValue : 0);
        const scrollTop = this.#alignedScrollTop(element, align, scrollMargin);

        // The spacer lengthens the transcript so a turn near the end can still settle near the top.
        this.#setSpacerHeight(scrollTop + viewport.clientHeight - this.#contentBottom());
        // Rows prepended before this scroll settles keep the target in place, instead of the row seen before.
        this.#prependAnchor = { element, viewportTop: elementViewportTop(element, viewport) };
        this.#mode = keepPreviousPeek ? ANCHORED : SETTLING_JUMP;
        this.#anchoredItem = keepPreviousPeek ? element : null;

        this.#scrollToPosition(scrollTop, { behavior });
        this.#scheduleCurrentAnchorSync();
    }

    #scrollToPosition(scrollTop, { behavior = 'auto', autoscrolling = false } = {}) {
        const viewport = this.viewportTarget;
        const top = Math.max(0, scrollTop);

        if (Math.abs(viewport.scrollTop - top) <= SCROLL_POSITION_EPSILON) {
            viewport.scrollTop = top;
            this.#commitScrollState();

            return;
        }

        if (autoscrolling) {
            this.#setAutoscrolling(true);
        }

        viewport.scrollTo({ top, behavior });
        this.#scheduleStateCommit();
    }

    #alignedScrollTop(element, align, scrollMargin) {
        const viewport = this.viewportTarget;
        const top = elementScrollTop(element, viewport);
        const height = element.getBoundingClientRect().height;
        const padding = blockPadding(this.contentTarget);

        if ('center' === align) {
            const insetHeight = Math.max(0, viewport.clientHeight - padding.start - padding.end);

            return top - padding.start - (insetHeight - height) / 2 - scrollMargin;
        }

        if ('end' === align) {
            return top - viewport.clientHeight + height + padding.end + scrollMargin;
        }

        if ('nearest' === align) {
            const visibleTop = viewport.scrollTop + padding.start;
            const visibleBottom = viewport.scrollTop + viewport.clientHeight - padding.end;

            if (top >= visibleTop && top + height <= visibleBottom) {
                return viewport.scrollTop;
            }

            if (top >= visibleTop) {
                return top + height - viewport.clientHeight + padding.end + scrollMargin;
            }
        }

        return top - padding.start - scrollMargin;
    }

    #setSpacerHeight(height) {
        const nextHeight = Math.max(0, Math.ceil(height));
        if (nextHeight === this.#spacerHeight) {
            return;
        }

        this.#spacerHeight = nextHeight;
        this.spacerTarget.hidden = 0 === nextHeight;
        this.spacerTarget.style.height = `${nextHeight}px`;
        // The spacer is not a row: it cancels the gap the content puts before it.
        this.spacerTarget.style.marginTop = nextHeight > 0 ? `${-flexGap(this.contentTarget)}px` : '';
    }

    #setAutoscrolling(autoscrolling) {
        clearTimeout(this.#autoscrollingTimeout);
        this.#autoscrollingTimeout = null;

        if (autoscrolling !== this.#autoscrolling) {
            this.#autoscrolling = autoscrolling;
            this.#commitScrollState();
        }

        if (autoscrolling) {
            this.#autoscrollingTimeout = setTimeout(() => {
                this.#autoscrollingTimeout = null;
                this.#autoscrolling = false;
                this.#commitScrollState();
            }, AUTOSCROLLING_CLEAR_DELAY);
        }
    }

    #scheduleStateCommit() {
        if (null !== this.#stateFrame) {
            return;
        }

        this.#stateFrame = requestAnimationFrame(() => {
            this.#stateFrame = null;
            this.#commitScrollState();
        });
    }

    #commitScrollState() {
        const viewport = this.viewportTarget;
        const scrollable = {
            start: viewport.scrollTop > this.scrollEdgeThresholdValue,
            end: this.#contentBottom() - viewport.scrollTop - viewport.clientHeight > this.scrollEdgeThresholdValue,
        };

        this.#reconcileFollowMode(scrollable);

        // While following, the scroller is already closing the gap a streamed chunk opened:
        // reporting it would make the scroll button flicker once per chunk.
        this.#writeScrollState(FOLLOWING_END === this.#mode ? { ...scrollable, end: false } : scrollable);
    }

    // Following starts at the end and stops on any scroll up, a scrollbar drag included. It does not start while a
    // turn is anchored: the spacer makes that turn read as "at the end", and the first streamed chunk would then pull
    // the reader off it. Content growing below the live edge is not a scroll up, and does not stop the following.
    #reconcileFollowMode(scrollable) {
        const scrollTop = this.viewportTarget.scrollTop;
        const scrolledUp = scrollTop < this.#lastScrollTop - SCROLL_POSITION_EPSILON;
        this.#lastScrollTop = scrollTop;

        if (this.autoScrollValue && !scrollable.end && SETTLING_JUMP !== this.#mode && ANCHORED !== this.#mode) {
            this.#mode = FOLLOWING_END;
        } else if (FOLLOWING_END === this.#mode && scrollable.end && scrolledUp && !this.#autoscrolling) {
            this.#mode = FREE_SCROLLING;
        }
    }

    #writeScrollState({ start, end }) {
        const scrollable = [start && 'start', end && 'end'].filter(Boolean).join(' ');

        for (const element of [this.element, ...this.rootTargets, this.viewportTarget]) {
            if (scrollable) {
                element.setAttribute('data-scrollable', scrollable);
            } else {
                element.removeAttribute('data-scrollable');
            }
            element.toggleAttribute('data-autoscrolling', this.#autoscrolling);
        }

        for (const button of this.buttonTargets) {
            const active = 'start' === button.dataset.direction ? start : end;

            button.dataset.active = active ? 'true' : 'false';
            button.inert = !active;
            if (active) {
                button.removeAttribute('tabindex');
            } else {
                button.tabIndex = -1;
            }
        }
    }

    // The current turn is the last anchor that reached the reading line, where anchored turns settle. It stays
    // current once its top scrolls out of view, and a turn only peeking above the line is not current yet.
    #scheduleCurrentAnchorSync() {
        if (!this.hasAnchorLinkTarget || null !== this.#currentAnchorFrame) {
            return;
        }

        this.#currentAnchorFrame = requestAnimationFrame(() => {
            this.#currentAnchorFrame = null;

            const readingLine =
                this.viewportTarget.getBoundingClientRect().top +
                this.scrollMarginValue +
                this.scrollPreviousItemPeekValue;
            const current = this.#items().findLast(
                (item) =>
                    isScrollAnchor(item) &&
                    item.dataset.messageId &&
                    item.getBoundingClientRect().top <= readingLine + SCROLL_POSITION_EPSILON
            );
            const currentId = current?.dataset.messageId ?? null;
            if (currentId === this.#currentAnchorId) {
                return;
            }
            this.#currentAnchorId = currentId;

            for (const link of this.anchorLinkTargets) {
                const isCurrent = null !== currentId && link.dataset.messageScrollerIdParam === currentId;

                link.setAttribute('aria-current', isCurrent ? 'location' : 'false');
            }
        });
    }

    #capturePrependAnchor() {
        const viewportRect = this.viewportTarget.getBoundingClientRect();
        this.#prependAnchor = null;

        for (const item of this.#items()) {
            if (!item.dataset.messageId) {
                continue;
            }

            const rect = item.getBoundingClientRect();
            if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom) {
                this.#prependAnchor = { element: item, viewportTop: rect.top - viewportRect.top };

                return;
            }
        }
    }

    // Compares the row with its place in the viewport, so this does nothing where the browser's own scroll
    // anchoring already kept it there, and only corrects the scroll where it did not.
    #restorePrependAnchor() {
        const anchor = this.#prependAnchor;
        if (!anchor?.element.isConnected) {
            return;
        }

        const delta = elementViewportTop(anchor.element, this.viewportTarget) - anchor.viewportTop;
        if (Math.abs(delta) <= SCROLL_POSITION_EPSILON) {
            return;
        }

        this.viewportTarget.scrollTop += delta;
        this.#scheduleStateCommit();
        this.#scheduleCurrentAnchorSync();
    }

    #contentBottom() {
        const viewport = this.viewportTarget;
        const padding = blockPadding(this.contentTarget);
        const viewportTop = viewport.getBoundingClientRect().top;
        let bottom = padding.start + padding.end;

        for (const item of this.#items()) {
            bottom = Math.max(
                bottom,
                item.getBoundingClientRect().bottom - viewportTop + viewport.scrollTop + padding.end
            );
        }

        return bottom;
    }

    // Rows added with a plain append, like a Turbo Stream, land after the spacer, which must stay the last child.
    // Rows that replace the whole content, like a Turbo Stream `update`, remove it: it is created again.
    #keepSpacerLast() {
        if (!this.hasSpacerTarget) {
            const spacer = document.createElement('div');
            spacer.dataset.messageScrollerTarget = 'spacer';
            spacer.setAttribute('aria-hidden', 'true');
            spacer.hidden = true;
            this.#spacerHeight = 0;
            this.contentTarget.append(spacer);
            this.#contentObserver.takeRecords();

            return;
        }

        if (this.contentTarget.lastElementChild === this.spacerTarget) {
            return;
        }

        this.contentTarget.append(this.spacerTarget);
        this.#contentObserver.takeRecords();
    }

    #items() {
        // Looked up once: a Stimulus target getter queries the whole controller element on each call.
        const spacer = this.hasSpacerTarget ? this.spacerTarget : null;

        return Array.from(this.contentTarget.children).filter((child) => child !== spacer);
    }

    #findItem(messageId) {
        return this.#items().find((item) => item.dataset.messageId === messageId) ?? null;
    }

    #preservesScrollOnPrepend() {
        return 'false' !== this.viewportTarget.dataset.preserveScrollOnPrepend;
    }

    #markDefaultPositionApplied() {
        this.#defaultPositionApplied = true;
        this.#clearPendingScroll();
    }

    #clearPendingScroll() {
        for (const element of [...this.rootTargets, this.viewportTarget]) {
            element.removeAttribute('data-pending-scroll');
        }
    }

    // The button turns inert once the viewport reaches its edge, so it must not keep the focus.
    #blurButton(event) {
        if (this.buttonTargets.includes(event?.currentTarget)) {
            event.currentTarget.blur();
        }
    }
}

function isScrollAnchor(item) {
    return 'true' === item.dataset.scrollAnchor;
}

function elementScrollTop(element, viewport) {
    return elementViewportTop(element, viewport) + viewport.scrollTop;
}

function elementViewportTop(element, viewport) {
    return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top;
}

function blockPadding(element) {
    const style = getComputedStyle(element);

    return { start: parseFloat(style.paddingBlockStart) || 0, end: parseFloat(style.paddingBlockEnd) || 0 };
}

function flexGap(element) {
    return parseFloat(getComputedStyle(element).rowGap) || 0;
}
templates/components/MessageScroller.html.twig
{%- set _messageScroller_pendingScroll = inject('messageScroller.pendingScroll', false) -%}
<div
    data-slot="message-scroller"
    data-message-scroller-target="root"
    {% if _messageScroller_pendingScroll %}data-pending-scroll{% endif %}
    {{ attributes.defaults({
        class: 'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden'|tailwind_classes,
    }) }}
>
    {##- A `MessageScroller:Viewport` and a `MessageScroller:Button`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/MessageScroller/Button.html.twig
{%- props
    ## 'start'|'end' The edge of the transcript the button scrolls to.
    direction = 'end',
    ## 'auto'|'smooth' How the viewport scrolls to that edge.
    behavior = 'smooth',
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The button style variant.
    variant = 'secondary',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'icon-sm'
-%}
<twig:Button
    variant="{{ variant }}"
    size="{{ size }}"
    data-slot="message-scroller-button"
    data-message-scroller-target="button"
    data-message-scroller-behavior-param="{{ behavior }}"
    data-direction="{{ direction }}"
    data-active="false"
    inert
    tabindex="-1"
    {{ ...attributes.defaults({
        class: 'absolute inset-s-1/2 -translate-x-1/2 border-border bg-background text-foreground transition-[translate,scale,opacity] duration-200 hover:bg-muted hover:text-foreground data-[active=false]:pointer-events-none data-[active=false]:scale-95 data-[active=false]:opacity-0 data-[active=false]:duration-400 data-[active=false]:ease-[cubic-bezier(0.7,0,0.84,0)] data-[active=true]:translate-y-0 data-[active=true]:scale-100 data-[active=true]:opacity-100 data-[active=true]:ease-[cubic-bezier(0.23,1,0.32,1)] data-[direction=end]:bottom-4 data-[direction=end]:data-[active=false]:translate-y-full data-[direction=start]:top-4 data-[direction=start]:data-[active=false]:-translate-y-full rtl:translate-x-1/2 data-[direction=start]:[&_svg]:rotate-180'|tailwind_classes,
        'data-action': ('click->message-scroller#' ~ ('start' == direction ? 'scrollToStart' : 'scrollToEnd'))|html_attr_type('sst'),
    }) }}
>
    {%- set _messageScroller_buttonContent -%}
        {##- The button content, an arrow and a visually hidden label by default. -#}
        {{- block(outerBlocks.content) -}}
    {%- endset -%}
    {%- if _messageScroller_buttonContent|trim is not empty -%}
        {{ _messageScroller_buttonContent }}
    {%- else -%}
        <twig:ux:icon name="lucide:arrow-down" />
        <span class="sr-only">{{ 'Scroll to ' ~ direction }}</span>
    {%- endif -%}
</twig:Button>
templates/components/MessageScroller/Content.html.twig
<div
    data-slot="message-scroller-content"
    data-message-scroller-target="content"
    role="{{ attributes.render('role')|default('log') }}"
    aria-relevant="{{ attributes.render('aria-relevant')|default('additions') }}"
    {{ attributes.defaults({
        class: 'flex h-max min-h-full flex-col gap-6'|tailwind_classes,
    }) }}
>
    {##- The `MessageScroller:Item` rows of the transcript. -#}
    {%- block content %}{% endblock -%}
    <div data-message-scroller-target="spacer" aria-hidden="true" hidden></div>
</div>
templates/components/MessageScroller/Item.html.twig
{%- props
    ## string|null Stable id of the row, used by `scrollToMessage`, the anchor links and the prepend preservation.
    messageId = null,
    ## boolean Whether the row starts a new turn, which settles near the top of the viewport when it is appended.
    scrollAnchor = false
-%}
<div
    data-slot="message-scroller-item"
    {% if messageId is not null %}data-message-id="{{ messageId }}"{% endif %}
    data-scroll-anchor="{{ scrollAnchor ? 'true' : 'false' }}"
    {{ attributes.defaults({
        class: 'min-w-0 shrink-0'|tailwind_classes,
    }) }}
>
    {##- A message, a marker, a typing indicator or any other row of the transcript. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/MessageScroller/Provider.html.twig
{%- props
    ## boolean Whether the viewport follows new content while it is at the end of the transcript.
    autoScroll = false,
    ## 'start'|'end'|'last-anchor' Where the transcript opens on the first render.
    defaultScrollPosition = 'end',
    ## int Distance in pixels from an edge that still counts as being at that edge.
    scrollEdgeThreshold = 8,
    ## int Pixels of the previous item kept visible above a newly anchored item.
    scrollPreviousItemPeek = 64,
    ## int Margin in pixels kept on the aligned edge when scrolling to an item.
    scrollMargin = 0
-%}
{%- do provide('messageScroller.pendingScroll', defaultScrollPosition in ['end', 'last-anchor']) -%}
<div
    data-slot="message-scroller-provider"
    data-message-scroller-auto-scroll-value="{{ autoScroll ? 'true' : 'false' }}"
    data-message-scroller-default-scroll-position-value="{{ defaultScrollPosition }}"
    data-message-scroller-scroll-edge-threshold-value="{{ scrollEdgeThreshold }}"
    data-message-scroller-scroll-previous-item-peek-value="{{ scrollPreviousItemPeek }}"
    data-message-scroller-scroll-margin-value="{{ scrollMargin }}"
    {{ attributes.defaults({
        class: 'group/message-scroller-provider contents'|tailwind_classes,
        'data-controller': 'message-scroller',
    }) }}
>
    {##- A `MessageScroller`, and any control that drives it, such as a button calling `scrollToMessage`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/MessageScroller/Viewport.html.twig
{%- props
    ## boolean Whether the first visible item keeps its place when older items are added above it.
    preserveScrollOnPrepend = true
-%}
{%- set _messageScroller_pendingScroll = inject('messageScroller.pendingScroll', false) -%}
<div
    data-slot="message-scroller-viewport"
    data-message-scroller-target="viewport"
    data-preserve-scroll-on-prepend="{{ preserveScrollOnPrepend ? 'true' : 'false' }}"
    role="{{ attributes.render('role')|default('region') }}"
    {% if _messageScroller_pendingScroll %}data-pending-scroll{% endif %}
    {{ attributes.defaults({
        class: 'size-full min-h-0 min-w-0 scroll-fade-b overflow-y-auto overscroll-contain contain-content [scrollbar-gutter:stable] [scrollbar-width:thin] data-autoscrolling:[scrollbar-color:transparent_transparent] [@media(scripting:enabled)]:data-pending-scroll:invisible'|tailwind_classes,
        'aria-label': 'Messages',
        tabindex: '0',
        'data-action': 'scroll->message-scroller#handleScroll wheel->message-scroller#handleUserScroll:passive touchmove->message-scroller#handleUserScroll:passive keydown->message-scroller#handleUserKeydown'|html_attr_type('sst'),
    }) }}
>
    {##- A `MessageScroller:Content`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Button.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
    variant = 'default',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'default',
    ## 'button'|'a' The HTML tag to render; use `a` when the button navigates.
    as = 'button'
-%}
{%- set style = html_cva(
    base: "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
    variants: {
        variant: {
            default: 'bg-primary text-primary-foreground hover:bg-primary/80',
            outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
            secondary: 'bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
            ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
            destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
            link: 'text-primary underline-offset-4 hover:underline',
        },
        size: {
            default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
            xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
            sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
            lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 has-data-[icon=inline-start]:ps-2',
            icon: 'size-8',
            'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
            'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
            'icon-lg': 'size-9',
        },
    },
) -%}
<{{ as }}
    data-slot="{{ attributes.render('data-slot')|default('button') }}"
    data-variant="{{ variant }}"
    data-size="{{ size }}"
    {{ attributes.defaults({
        class: style.apply({variant: variant, size: size})|tailwind_classes,
        type: as == 'button' ? 'button' : false,
    }) }}
>
    {##- The button label and/or icon. -#}
    {%- block content %}{% endblock -%}
</{{ as }}>

Usage

<twig:MessageScroller:Provider>
    <twig:MessageScroller>
        <twig:MessageScroller:Viewport>
            <twig:MessageScroller:Content>
                {% for message in messages %}
                    <twig:MessageScroller:Item messageId="{{ message.id }}" :scrollAnchor="message.role == 'user'">
                        <twig:Message>...</twig:Message>
                    </twig:MessageScroller:Item>
                {% endfor %}
            </twig:MessageScroller:Content>
        </twig:MessageScroller:Viewport>
        <twig:MessageScroller:Button />
    </twig:MessageScroller>
</twig:MessageScroller:Provider>

MessageScroller fills its parent, so place it inside a height-constrained container.

<div class="flex h-screen flex-col">
    <twig:MessageScroller:Provider>
        <twig:MessageScroller class="flex-1">{# transcript #}</twig:MessageScroller>
    </twig:MessageScroller:Provider>
</div>

The scroller does not load or send messages. Add rows to MessageScroller:Content the way the page already updates, for instance with a Turbo Stream append or prepend: the scroller reacts to every row added or removed. Replacing MessageScroller:Content as a whole, for instance with a Turbo Stream replace, opens the new transcript at its default position.

For a transcript of hundreds of rows, let the browser skip the rendering work of the rows far from the viewport: add [content-visibility:auto] [contain-intrinsic-size:auto_10rem] to the class of MessageScroller:Item. The rows stay in the page, for text selection, find in page and assistive technologies.

Examples

Anchoring Turns

A turn is the part of the conversation that starts a new exchange. In a simple AI chat, that is usually the user's message and the assistant reply that follows.

An anchor is the row the viewport treats as the start of that turn. Mark that row with scrollAnchor. When a new anchor is appended, the viewport moves it near the top and keeps a peek of the previous row above it, so the new turn does not feel detached from its context. Anchors are not tied to a role: any row can start a turn.

100%
Loading...
<twig:MessageScroller:Provider>
    <twig:Card class="">
        <twig:Card:Header class="">
            <twig:Card:Title>Anchoring Turns</twig:Card:Title>
            <twig:Card:Description>The user messages settle near the top edge.</twig:Card:Description>
        </twig:Card:Header>
        <twig:Card:Content class="">
            <twig:MessageScroller>
                <twig:MessageScroller:Viewport>
                    <twig:MessageScroller:Content class="">
                        <twig:MessageScroller:Item messageId="anchor-1" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>Can you show me how anchoring behaves when a new prompt starts the turn?</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="anchor-2">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content>Append the user prompt first, then append the assistant response. With the user prompt as the anchor, the prompt settles near the top and the assistant response fills in below it.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="anchor-3" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>What changes when assistant messages are the anchor?</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="anchor-4">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content>Then each assistant response is the item `MessageScroller` keeps in view. This is useful when the reply is the moment you want readers to land on after each turn.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="anchor-5" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>Can I switch roles and keep adding turns?</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="anchor-6">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content>Yes. The next appended message with the selected role becomes the anchor, so you can compare user and assistant anchoring in the same transcript.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                    </twig:MessageScroller:Content>
                </twig:MessageScroller:Viewport>
                <twig:MessageScroller:Button />
            </twig:MessageScroller>
        </twig:Card:Content>
    </twig:Card>
</twig:MessageScroller:Provider>

Group Chat

In a group chat, the turn boundary is often the message that asks the model to respond, or a marker like "Rocky has joined the chat". Typing indicators and history controls usually should not anchor. Because anchoring is not tied to a role, a marker can anchor just like a message.

100%
Loading...
<twig:MessageScroller:Provider>
    <twig:Card class="">
        <twig:Card:Header class="">
            <twig:Card:Title>Group Chat</twig:Card:Title>
            <twig:Card:Description>Several participants and an assistant. The marker starts a turn.</twig:Card:Description>
        </twig:Card:Header>
        <twig:Card:Content class="">
            <twig:MessageScroller>
                <twig:MessageScroller:Viewport>
                    <twig:MessageScroller:Content class="">
                        <twig:MessageScroller:Item messageId="group-1">
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>@mary, the astrophage line keeps matching Venus energy output. Can you check my math?</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="group-2">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Message:Header>Mary (Agent)</twig:Message:Header>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content>Yes. Confirmed. The curve points to a microorganism harvesting stellar energy and breeding near carbon dioxide. If @rocky agrees, this is the clue we need.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="group-3" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>ping @rocky</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item scrollAnchor>
                            <twig:Marker variant="separator">
                                <twig:Marker:Content>Rocky has joined the chat</twig:Marker:Content>
                            </twig:Marker>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="group-5">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Message:Header>Rocky</twig:Message:Header>
                                    <twig:Bubble variant="tinted">
                                        <twig:Bubble:Content>Amaze. Astrophage eats light, makes heat, goes to carbon dioxide. Rocky has fuel model. Grace is smart.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                    </twig:MessageScroller:Content>
                </twig:MessageScroller:Viewport>
                <twig:MessageScroller:Button />
            </twig:MessageScroller>
        </twig:Card:Content>
    </twig:Card>
</twig:MessageScroller:Provider>

Keeping Context Visible

When a new turn starts, it should still feel like part of the same thread. scrollPreviousItemPeek keeps a slice of the previous row visible above the anchor, 64 pixels by default, so the reader keeps their context instead of feeling like the conversation restarted on a blank page. Here it keeps 96 pixels, below a scrollMargin of 24 pixels, and the transcript opens on its last turn.

100%
Loading...
<twig:MessageScroller:Provider defaultScrollPosition="last-anchor" scrollMargin="24" scrollPreviousItemPeek="96">
    <twig:Card class="">
        <twig:Card:Header class="">
            <twig:Card:Title>Keeping Context Visible</twig:Card:Title>
            <twig:Card:Description>New turns keep part of the previous reply in view.</twig:Card:Description>
        </twig:Card:Header>
        <twig:Card:Content class="">
            <twig:MessageScroller>
                <twig:MessageScroller:Viewport>
                    <twig:MessageScroller:Content class="">
                        <twig:MessageScroller:Item messageId="context-1" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="context-2">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content class="">
                                            <p class="">That's the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll`: the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.</p>
                                            <p class="">The important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user's intent.</p>
                                        </twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="context-3" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>Okay, but when someone sends a new message the view still feels jarring, like the whole conversation reloads from the top.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="context-4">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content class="">
                                            <p class="">`MessageScroller:Item` fixes that with turn anchoring. Set `scrollAnchor` on the turn that should settle near the top instead of blindly snapping to the document bottom.</p>
                                            <p class="">It also leaves a small peek of the previous exchange visible above the anchor, so context isn't lost. The reply starts in view without that disorienting jump you get from a plain overflow container.</p>
                                        </twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="context-5" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>And if they've scrolled up to re-read an older answer? I don't want to yank them back down.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="context-6">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content class="">
                                            <p class="">You won't. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out: their place in the thread stays put even as new tokens keep arriving below.</p>
                                            <p class="">When there is content they haven't seen yet, `MessageScroller:Button` appears at the bottom of the viewport. One tap jumps them back to the newest message and re-engages auto-scroll. Same pattern as Slack or iMessage: quiet when you're caught up, helpful when you're not.</p>
                                            <p class="">`MessageScroller:Content` sets `role="log"` and `aria-relevant="additions"` by default, so screen readers announce new messages as they stream in.</p>
                                            <p class="">The scroll button is a real &lt;button&gt; with a visually hidden label, and it's removed from the tab order when you're already at the bottom: no ghost focus stops.</p>
                                        </twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                    </twig:MessageScroller:Content>
                </twig:MessageScroller:Viewport>
                <twig:MessageScroller:Button />
            </twig:MessageScroller>
        </twig:Card:Content>
    </twig:Card>
</twig:MessageScroller:Provider>

Following the Live Edge

When the reader is at the live edge, autoScroll keeps a streamed reply in view as it grows. Scrolling away from the live edge releases the view, whether by wheel, touch, keyboard or the scrollbar, and new chunks then arrive without moving the reader. autoScroll works with turn anchoring: a new turn settles near the top, and the view only starts following once the reply fills the viewport.

Pressing MessageScroller:Button, or calling scrollToEnd, follows the live edge again. The frame and the viewport have data-autoscrolling while that scroll runs. Set aria-busy on MessageScroller:Content while a reply streams, so screen readers wait for the complete row.

100%
Loading...
<twig:MessageScroller:Provider autoScroll>
    <twig:Card class="">
        <twig:Card:Header class="">
            <twig:Card:Title>Streaming Messages</twig:Card:Title>
            <twig:Card:Description>Auto-scroll follows the live edge of the conversation.</twig:Card:Description>
        </twig:Card:Header>
        <twig:Card:Content class="">
            <twig:MessageScroller>
                <twig:MessageScroller:Viewport>
                    <twig:MessageScroller:Content class="" aria-busy="true">
                        <twig:MessageScroller:Item messageId="stream-1" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="stream-2">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content class="">
                                            <p class="">That's the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll`: the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.</p>
                                            <p class="">The important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user's intent.</p>
                                        </twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="stream-3" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>Okay, but when someone sends a new message the view still feels jarring, like the whole conversation reloads from the top.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="stream-4">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content>`MessageScroller:Item` fixes that with turn anchoring. Set `scrollAnchor` on the turn that should settle near the top instead of blindly snapping to the document bottom.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                    </twig:MessageScroller:Content>
                </twig:MessageScroller:Viewport>
                <twig:MessageScroller:Button />
            </twig:MessageScroller>
        </twig:Card:Content>
    </twig:Card>
</twig:MessageScroller:Provider>

Opening Saved Threads

Reopening a saved thread at the very end often drops the reader into the conversation without enough context. defaultScrollPosition="last-anchor" shows the last meaningful turn instead, like the user's latest message, with the reply below it. When there is no anchor, or the last turn already fits in the viewport, it opens at the end. Use start to resume at the beginning of a conversation, or end, the default, to land on the latest message.

A scroll container always opens at the top, so the viewport stays hidden until the controller applies an end or last-anchor position: you see the frame instead of a jump.

100%
Loading...
<twig:Card class="">
    <twig:Card:Header class="">
        <twig:Card:Title>Opening Position</twig:Card:Title>
        <twig:Card:Description>Where the transcript starts when you open the thread.</twig:Card:Description>
    </twig:Card:Header>
    <twig:Tabs defaultValue="last-anchor" class="">
        {%- for position in ['start', 'end', 'last-anchor'] %}
            <twig:Tabs:Content :value="position" class="">
                <twig:MessageScroller:Provider :defaultScrollPosition="position">
                    <twig:MessageScroller>
                        <twig:MessageScroller:Viewport>
                            <twig:MessageScroller:Content class="">
                                <twig:MessageScroller:Item messageId="open-1" scrollAnchor>
                                    <twig:Message align="end">
                                        <twig:Message:Content>
                                            <twig:Bubble variant="muted">
                                                <twig:Bubble:Content>This is the first message the user sent in the conversation.</twig:Bubble:Content>
                                            </twig:Bubble>
                                        </twig:Message:Content>
                                    </twig:Message>
                                </twig:MessageScroller:Item>
                                <twig:MessageScroller:Item messageId="open-2">
                                    <twig:Message>
                                        <twig:Message:Content>
                                            <twig:Bubble variant="ghost">
                                                <twig:Bubble:Content>Workspace creation rose 8%, but first invite completion only rose 2%.</twig:Bubble:Content>
                                            </twig:Bubble>
                                        </twig:Message:Content>
                                    </twig:Message>
                                </twig:MessageScroller:Item>
                                <twig:MessageScroller:Item messageId="open-3" scrollAnchor>
                                    <twig:Message align="end">
                                        <twig:Message:Content>
                                            <twig:Bubble variant="muted">
                                                <twig:Bubble:Content>This is the last message the user sent in the conversation.</twig:Bubble:Content>
                                            </twig:Bubble>
                                        </twig:Message:Content>
                                    </twig:Message>
                                </twig:MessageScroller:Item>
                                <twig:MessageScroller:Item messageId="open-4">
                                    <twig:Message>
                                        <twig:Message:Content>
                                            <twig:Bubble variant="ghost">
                                                <twig:Bubble:Content class="">
                                                    <p class="">Start with the invite step. Teams are creating workspaces but waiting to add collaborators.</p>
                                                    <p class="">Recommended follow-up:</p>
                                                    <p class="">1. Compare invite drop-off by account size.
                                                2. Check whether users who skip invites still return within 24 hours.
                                                3. Review the empty-state copy on the first project screen.
                                                4. Segment activation by template, since template users may not need invites right away.</p>
                                                    <p class="">If that pattern holds, the next experiment should make collaboration useful earlier instead of prompting for invites harder.</p>
                                                </twig:Bubble:Content>
                                            </twig:Bubble>
                                        </twig:Message:Content>
                                    </twig:Message>
                                </twig:MessageScroller:Item>
                            </twig:MessageScroller:Content>
                        </twig:MessageScroller:Viewport>
                        <twig:MessageScroller:Button />
                    </twig:MessageScroller>
                </twig:MessageScroller:Provider>
            </twig:Tabs:Content>
        {%- endfor %}
        <twig:Card:Footer class="">
            <twig:Tabs:List class="">
                <twig:Tabs:Trigger value="start">start</twig:Tabs:Trigger>
                <twig:Tabs:Trigger value="end">end</twig:Tabs:Trigger>
                <twig:Tabs:Trigger value="last-anchor">last-anchor</twig:Tabs:Trigger>
            </twig:Tabs:List>
        </twig:Card:Footer>
    </twig:Tabs>
</twig:Card>

Loading Earlier Messages

Loading earlier messages should not move the conversation the reader is looking at. When rows are prepended to MessageScroller:Content, the viewport keeps the first visible row in place while the history loads above it. Turn it off with preserveScrollOnPrepend="false" on MessageScroller:Viewport. Give message rows a stable messageId: the scroller holds a specific row instead of whatever happens to sit at the viewport edge.

100%
Loading...
<twig:MessageScroller:Provider>
    <twig:Card class="">
        <twig:Card:Header class="">
            <twig:Card:Title>Load History</twig:Card:Title>
            <twig:Card:Description>Older messages load above without moving the reader.</twig:Card:Description>
        </twig:Card:Header>
        <twig:Card:Content class="">
            <twig:MessageScroller>
                <twig:MessageScroller:Viewport>
                    <twig:MessageScroller:Content class="">
                        <twig:MessageScroller:Item>
                            <div class="">
                                <twig:Button variant="outline" size="sm">Load earlier messages</twig:Button>
                            </div>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="history-1" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>Can you summarize where the launch checklist stands?</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="history-2">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content>Design review is done, the pricing page is in copy review, and the migration guide still needs an owner.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="history-3" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>Who should own the migration guide?</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="history-4">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content>Jordan wrote the previous one and knows the edge cases. If Jordan is busy, Priya has reviewed every migration this quarter.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="history-5" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>Let's ask Jordan first. What's left after that?</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="history-6">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content>Only the announcement email. It depends on the final pricing copy, so it can start as soon as copy review signs off.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                    </twig:MessageScroller:Content>
                </twig:MessageScroller:Viewport>
                <twig:MessageScroller:Button />
            </twig:MessageScroller>
        </twig:Card:Content>
    </twig:Card>
</twig:MessageScroller:Provider>

Animating New Messages

A MessageScroller:Item can be animated directly. A common chat pattern animates the user's message when it is sent, and lets the reply stream into a regular row below it. Animate transform and opacity only: an entrance that animates the height, the margin or the padding fights the positioning of the scroller. Skip the animation when the reader prefers reduced motion.

100%
Loading...
<twig:MessageScroller:Provider>
    <twig:Card class="">
        <twig:Card:Header class="">
            <twig:Card:Title>Animation</twig:Card:Title>
            <twig:Card:Description>User messages rise from the live edge as they are sent.</twig:Card:Description>
        </twig:Card:Header>
        <twig:Card:Content class="">
            <twig:MessageScroller>
                <twig:MessageScroller:Viewport>
                    <twig:MessageScroller:Content class="">
                        <twig:MessageScroller:Item messageId="animation-1" scrollAnchor class="">
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="animation-2">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content class="">
                                            <p class="">That's the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll`: the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.</p>
                                            <p class="">The important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user's intent.</p>
                                        </twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="animation-3" scrollAnchor class="">
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>Okay, but when someone sends a new message the view still feels jarring, like the whole conversation reloads from the top.</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="animation-4">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content class="">
                                            <p class="">`MessageScroller:Item` fixes that with turn anchoring. Set `scrollAnchor` on the turn that should settle near the top instead of blindly snapping to the document bottom.</p>
                                            <p class="">It also leaves a small peek of the previous exchange visible above the anchor, so context isn't lost. The reply starts in view without that disorienting jump you get from a plain overflow container.</p>
                                        </twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                    </twig:MessageScroller:Content>
                </twig:MessageScroller:Viewport>
                <twig:MessageScroller:Button />
            </twig:MessageScroller>
        </twig:Card:Content>
    </twig:Card>
</twig:MessageScroller:Provider>

Jumping to Messages

Search results, permalinks, outlines and toolbar buttons often drive the transcript from outside the list. The message-scroller controller has three actions for them: scrollToMessage, scrollToStart and scrollToEnd. They work from any element inside MessageScroller:Provider, including controls outside the MessageScroller frame.

scrollToMessage takes the messageId of the row in its id param, and optional align (start, center, end or nearest) and behavior (auto or smooth) params. When no row is rendered yet, the jump waits for the transcript to arrive.

100%
Loading...
<twig:MessageScroller:Provider>
    <twig:Card class="">
        <twig:Card:Header class="">
            <twig:Card:Title>Commands</twig:Card:Title>
            <twig:Card:Description>Drive the transcript from outside.</twig:Card:Description>
            <twig:Card:Action>
                <twig:DropdownMenu id="message-scroller-jump-menu" side="bottom" align="end">
                    <twig:DropdownMenu:Trigger>
                        <twig:Button variant="secondary" {{ ...dropdown_menu_trigger_attrs }}>Jump to...</twig:Button>
                    </twig:DropdownMenu:Trigger>
                    <twig:DropdownMenu:Content class="">
                        <twig:DropdownMenu:Group>
                            <twig:DropdownMenu:Label>Conversations</twig:DropdownMenu:Label>
                            <twig:DropdownMenu:Item
                                data-action="message-scroller#scrollToMessage"
                                data-message-scroller-id-param="command-activation"
                                data-message-scroller-behavior-param="smooth"
                            >
                                <span class="">We're seeing activation dip after works...</span>
                            </twig:DropdownMenu:Item>
                            <twig:DropdownMenu:Item
                                data-action="message-scroller#scrollToMessage"
                                data-message-scroller-id-param="command-compare"
                                data-message-scroller-behavior-param="smooth"
                            >
                                <span class="">What should I compare before we change ...</span>
                            </twig:DropdownMenu:Item>
                            <twig:DropdownMenu:Item
                                data-action="message-scroller#scrollToMessage"
                                data-message-scroller-id-param="command-experiment"
                                data-message-scroller-behavior-param="smooth"
                            >
                                <span class="">Can you turn that into an experiment?</span>
                            </twig:DropdownMenu:Item>
                            <twig:DropdownMenu:Item
                                data-action="message-scroller#scrollToMessage"
                                data-message-scroller-id-param="command-risk"
                                data-message-scroller-behavior-param="smooth"
                            >
                                <span class="">What's the risk if we delay the invite ...</span>
                            </twig:DropdownMenu:Item>
                        </twig:DropdownMenu:Group>
                    </twig:DropdownMenu:Content>
                </twig:DropdownMenu>
            </twig:Card:Action>
        </twig:Card:Header>
        <twig:Card:Content class="">
            <twig:MessageScroller>
                <twig:MessageScroller:Viewport>
                    <twig:MessageScroller:Content class="">
                        <twig:MessageScroller:Item messageId="command-activation" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>We're seeing activation dip after workspace creation. Can you help me find the likely step?</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="command-reply-1">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content class="">
                                            <p class="">The sharpest drop is between creating the workspace and inviting the first teammate.</p>
                                            <p class="">Workspace creation is still healthy, but the invite step is where users pause. That suggests the product is asking for collaboration before the user has enough confidence in the workspace.</p>
                                        </twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="command-compare" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>What should I compare before we change the onboarding flow?</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="command-reply-3">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content class="">
                                            <p class="">Compare three cohorts:</p>
                                            <p class="">1. Users who choose a template before inviting teammates.
                                        2. Users who start from a blank workspace.
                                        3. Users who skip invites and return within 24 hours.</p>
                                            <p class="">If template users invite faster, the fix is probably better first-run guidance rather than a louder invite prompt.</p>
                                        </twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="command-experiment" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>Can you turn that into an experiment?</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="command-reply-5">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content class="">
                                            <p class="">Yes. Create a variant that shows a short checklist after workspace creation:</p>
                                            <p class="">- Pick a template.
                                        - Add one project detail.
                                        - Invite a teammate when the workspace has context.</p>
                                            <p class="">Measure first invite completion, 24-hour return rate, and whether teams create a second project.</p>
                                        </twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="command-risk" scrollAnchor>
                            <twig:Message align="end">
                                <twig:Message:Content>
                                    <twig:Bubble variant="muted">
                                        <twig:Bubble:Content>What's the risk if we delay the invite prompt?</twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                        <twig:MessageScroller:Item messageId="command-reply-7">
                            <twig:Message>
                                <twig:Message:Content>
                                    <twig:Bubble variant="ghost">
                                        <twig:Bubble:Content class="">
                                            <p class="">The main risk is reducing team creation for accounts that already know who they want to invite.</p>
                                            <p class="">To protect that path, keep the invite action visible in the header and only change the primary empty-state guidance. That gives confident teams a direct route without forcing uncertain users through the invite step too early.</p>
                                        </twig:Bubble:Content>
                                    </twig:Bubble>
                                </twig:Message:Content>
                            </twig:Message>
                        </twig:MessageScroller:Item>
                    </twig:MessageScroller:Content>
                </twig:MessageScroller:Viewport>
                <twig:MessageScroller:Button />
            </twig:MessageScroller>
        </twig:Card:Content>
    </twig:Card>
</twig:MessageScroller:Provider>

Tracking the Reader's Position

An outline or a jump menu often highlights the turn the reader is in. Mark its controls as anchorLink targets of the controller: the one whose id param is the current turn gets aria-current="location", the others aria-current="false". The current turn is the last anchor to reach the line where anchored turns settle, and it stays current after its top scrolls out of view. The tracking only runs while the provider holds an anchorLink target.

100%
Loading...
<twig:MessageScroller:Provider scrollMargin="12">
    <div class="">
        <twig:Card class="">
            <twig:Card:Header class="">
                <twig:Card:Title>Transcript Outline</twig:Card:Title>
                <twig:Card:Description>Track the current anchored turn.</twig:Card:Description>
            </twig:Card:Header>
            <twig:Card:Content class="">
                <twig:MessageScroller>
                    <twig:MessageScroller:Viewport>
                        <twig:MessageScroller:Content class="">
                            <twig:MessageScroller:Item messageId="command-activation" scrollAnchor>
                                <twig:Message align="end">
                                    <twig:Message:Content>
                                        <twig:Bubble variant="muted">
                                            <twig:Bubble:Content>We're seeing activation dip after workspace creation. Can you help me find the likely step?</twig:Bubble:Content>
                                        </twig:Bubble>
                                    </twig:Message:Content>
                                </twig:Message>
                            </twig:MessageScroller:Item>
                            <twig:MessageScroller:Item messageId="command-reply-1">
                                <twig:Message>
                                    <twig:Message:Content>
                                        <twig:Bubble variant="ghost">
                                            <twig:Bubble:Content class="">
                                                <p class="">The sharpest drop is between creating the workspace and inviting the first teammate.</p>
                                                <p class="">Workspace creation is still healthy, but the invite step is where users pause. That suggests the product is asking for collaboration before the user has enough confidence in the workspace.</p>
                                            </twig:Bubble:Content>
                                        </twig:Bubble>
                                    </twig:Message:Content>
                                </twig:Message>
                            </twig:MessageScroller:Item>
                            <twig:MessageScroller:Item messageId="command-compare" scrollAnchor>
                                <twig:Message align="end">
                                    <twig:Message:Content>
                                        <twig:Bubble variant="muted">
                                            <twig:Bubble:Content>What should I compare before we change the onboarding flow?</twig:Bubble:Content>
                                        </twig:Bubble>
                                    </twig:Message:Content>
                                </twig:Message>
                            </twig:MessageScroller:Item>
                            <twig:MessageScroller:Item messageId="command-reply-3">
                                <twig:Message>
                                    <twig:Message:Content>
                                        <twig:Bubble variant="ghost">
                                            <twig:Bubble:Content class="">
                                                <p class="">Compare three cohorts:</p>
                                                <p class="">1. Users who choose a template before inviting teammates.
                                            2. Users who start from a blank workspace.
                                            3. Users who skip invites and return within 24 hours.</p>
                                                <p class="">If template users invite faster, the fix is probably better first-run guidance rather than a louder invite prompt.</p>
                                            </twig:Bubble:Content>
                                        </twig:Bubble>
                                    </twig:Message:Content>
                                </twig:Message>
                            </twig:MessageScroller:Item>
                            <twig:MessageScroller:Item messageId="command-experiment" scrollAnchor>
                                <twig:Message align="end">
                                    <twig:Message:Content>
                                        <twig:Bubble variant="muted">
                                            <twig:Bubble:Content>Can you turn that into an experiment?</twig:Bubble:Content>
                                        </twig:Bubble>
                                    </twig:Message:Content>
                                </twig:Message>
                            </twig:MessageScroller:Item>
                            <twig:MessageScroller:Item messageId="command-reply-5">
                                <twig:Message>
                                    <twig:Message:Content>
                                        <twig:Bubble variant="ghost">
                                            <twig:Bubble:Content class="">
                                                <p class="">Yes. Create a variant that shows a short checklist after workspace creation:</p>
                                                <p class="">- Pick a template.
                                            - Add one project detail.
                                            - Invite a teammate when the workspace has context.</p>
                                                <p class="">Measure first invite completion, 24-hour return rate, and whether teams create a second project.</p>
                                            </twig:Bubble:Content>
                                        </twig:Bubble>
                                    </twig:Message:Content>
                                </twig:Message>
                            </twig:MessageScroller:Item>
                            <twig:MessageScroller:Item messageId="command-risk" scrollAnchor>
                                <twig:Message align="end">
                                    <twig:Message:Content>
                                        <twig:Bubble variant="muted">
                                            <twig:Bubble:Content>What's the risk if we delay the invite prompt?</twig:Bubble:Content>
                                        </twig:Bubble>
                                    </twig:Message:Content>
                                </twig:Message>
                            </twig:MessageScroller:Item>
                            <twig:MessageScroller:Item messageId="command-reply-7">
                                <twig:Message>
                                    <twig:Message:Content>
                                        <twig:Bubble variant="ghost">
                                            <twig:Bubble:Content class="">
                                                <p class="">The main risk is reducing team creation for accounts that already know who they want to invite.</p>
                                                <p class="">To protect that path, keep the invite action visible in the header and only change the primary empty-state guidance. That gives confident teams a direct route without forcing uncertain users through the invite step too early.</p>
                                            </twig:Bubble:Content>
                                        </twig:Bubble>
                                    </twig:Message:Content>
                                </twig:Message>
                            </twig:MessageScroller:Item>
                        </twig:MessageScroller:Content>
                    </twig:MessageScroller:Viewport>
                    <twig:MessageScroller:Button />
                </twig:MessageScroller>
            </twig:Card:Content>
        </twig:Card>
        <div class="">
            <twig:HoverCard openDelay="10" closeDelay="100">
                <twig:HoverCard:Trigger>
                    <twig:Button variant="ghost" size="icon" aria-label="Open transcript outline" {{ ...hover_card_trigger_attrs }}>
                        <twig:ux:icon name="lucide:list" />
                    </twig:Button>
                </twig:HoverCard:Trigger>
                <twig:HoverCard:Content side="left" class="">
                    <button
                        type="button"
                        class=""
                        aria-current="false"
                        data-message-scroller-target="anchorLink"
                        data-action="message-scroller#scrollToMessage"
                        data-message-scroller-id-param="command-activation"
                        data-message-scroller-behavior-param="smooth"
                    >
                        <span class="">We're seeing activation dip after works...</span>
                    </button>
                    <button
                        type="button"
                        class=""
                        aria-current="false"
                        data-message-scroller-target="anchorLink"
                        data-action="message-scroller#scrollToMessage"
                        data-message-scroller-id-param="command-compare"
                        data-message-scroller-behavior-param="smooth"
                    >
                        <span class="">What should I compare before we change ...</span>
                    </button>
                    <button
                        type="button"
                        class=""
                        aria-current="false"
                        data-message-scroller-target="anchorLink"
                        data-action="message-scroller#scrollToMessage"
                        data-message-scroller-id-param="command-experiment"
                        data-message-scroller-behavior-param="smooth"
                    >
                        <span class="">Can you turn that into an experiment?</span>
                    </button>
                    <button
                        type="button"
                        class=""
                        aria-current="false"
                        data-message-scroller-target="anchorLink"
                        data-action="message-scroller#scrollToMessage"
                        data-message-scroller-id-param="command-risk"
                        data-message-scroller-behavior-param="smooth"
                    >
                        <span class="">What's the risk if we delay the invite ...</span>
                    </button>
                </twig:HoverCard:Content>
            </twig:HoverCard>
        </div>
    </div>
</twig:MessageScroller:Provider>

Reading Scroll State

The provider, the frame and the viewport have a data-scrollable attribute listing the edges the viewport can still scroll toward: start, end, or both. It is absent when everything fits. Style anything inside the provider from it, with the group/message-scroller-provider group, or the frame with group/message-scroller.

100%
Loading...
<twig:Card class="">
    <twig:Card:Header class="">
        <twig:Card:Title>Scroll Status</twig:Card:Title>
        <twig:Card:Description>Where the reader can scroll to from the current position.</twig:Card:Description>
    </twig:Card:Header>
    <twig:MessageScroller:Provider defaultScrollPosition="start">
        <twig:Card:Content class="">
            <twig:MessageScroller>
                <twig:MessageScroller:Viewport>
                    <twig:MessageScroller:Content class="">
                        {%- for checkpoint in [1, 3, 5, 7, 9, 11] %}
                            <twig:MessageScroller:Item :messageId="'scrollable-' ~ checkpoint" scrollAnchor>
                                <twig:Message align="end">
                                    <twig:Message:Content>
                                        <twig:Bubble variant="muted">
                                            <twig:Bubble:Content>Review scroll checkpoint {{ checkpoint }}.</twig:Bubble:Content>
                                        </twig:Bubble>
                                    </twig:Message:Content>
                                </twig:Message>
                            </twig:MessageScroller:Item>
                            <twig:MessageScroller:Item :messageId="'scrollable-' ~ (checkpoint + 1)">
                                <twig:Message>
                                    <twig:Message:Content>
                                        <twig:Bubble variant="ghost">
                                            <twig:Bubble:Content class="">
                                                <p class="">Checkpoint {{ checkpoint + 1 }} is synced. The scroll state updates as the viewport moves.</p>
                                                <p class="">When the reader is at the first message, the footer should only point them down. Once they move into the middle of the transcript, it should explain that both directions are available.</p>
                                                <p class="">At the latest message, the footer should switch again and only point them back up.</p>
                                            </twig:Bubble:Content>
                                        </twig:Bubble>
                                    </twig:Message:Content>
                                </twig:Message>
                            </twig:MessageScroller:Item>
                        {%- endfor %}
                    </twig:MessageScroller:Content>
                </twig:MessageScroller:Viewport>
                <twig:MessageScroller:Button />
            </twig:MessageScroller>
        </twig:Card:Content>
        <twig:Card:Footer class="">
            <p class="">You can scroll both ways.</p>
            <p class="">You are at the top. You can only scroll down.</p>
            <p class="">You are at the bottom. You can only scroll up.</p>
            <p class="">All messages fit in the viewport.</p>
        </twig:Card:Footer>
    </twig:MessageScroller:Provider>
</twig:Card>

RTL

To enable RTL support, set the dir="rtl" attribute on the root element.

100%
Loading...
<div class="">
    {# Arabic #}
    <div dir="rtl" class="">
        <twig:MessageScroller:Provider>
            <twig:Card class="">
                <twig:Card:Header class="">
                    <twig:Card:Title>محادثة جديدة</twig:Card:Title>
                    <twig:Card:Description>كيف يمكنني مساعدتك اليوم؟</twig:Card:Description>
                </twig:Card:Header>
                <twig:Card:Content class="">
                    <twig:MessageScroller>
                        <twig:MessageScroller:Viewport>
                            <twig:MessageScroller:Content class="">
                                <twig:MessageScroller:Item messageId="rtl-ar-1" scrollAnchor>
                                    <twig:Message align="end">
                                        <twig:Message:Content>
                                            <twig:Bubble variant="muted">
                                                <twig:Bubble:Content>لماذا يقفز المحادثة كلما وصل رد جديد؟</twig:Bubble:Content>
                                            </twig:Bubble>
                                        </twig:Message:Content>
                                    </twig:Message>
                                </twig:MessageScroller:Item>
                                <twig:MessageScroller:Item messageId="rtl-ar-2">
                                    <twig:Message>
                                        <twig:Message:Content>
                                            <twig:Bubble variant="ghost">
                                                <twig:Bubble:Content>لأن الحاوية تنتقل إلى الأسفل مع كل جزء جديد. يثبّت MessageScroller الدور الجديد قرب الأعلى، ولا يتابع الرد إلا إذا كان القارئ في النهاية.</twig:Bubble:Content>
                                            </twig:Bubble>
                                        </twig:Message:Content>
                                    </twig:Message>
                                </twig:MessageScroller:Item>
                                <twig:MessageScroller:Item messageId="rtl-ar-3" scrollAnchor>
                                    <twig:Message align="end">
                                        <twig:Message:Content>
                                            <twig:Bubble variant="muted">
                                                <twig:Bubble:Content>وإذا صعدتُ لقراءة رسالة قديمة؟</twig:Bubble:Content>
                                            </twig:Bubble>
                                        </twig:Message:Content>
                                    </twig:Message>
                                </twig:MessageScroller:Item>
                                <twig:MessageScroller:Item messageId="rtl-ar-4">
                                    <twig:Message>
                                        <twig:Message:Content>
                                            <twig:Bubble variant="ghost">
                                                <twig:Bubble:Content>يبقى مكانك كما هو. يظهر زر في أسفل العرض للعودة إلى آخر رسالة عندما تريد.</twig:Bubble:Content>
                                            </twig:Bubble>
                                        </twig:Message:Content>
                                    </twig:Message>
                                </twig:MessageScroller:Item>
                            </twig:MessageScroller:Content>
                        </twig:MessageScroller:Viewport>
                        <twig:MessageScroller:Button />
                    </twig:MessageScroller>
                </twig:Card:Content>
            </twig:Card>
        </twig:MessageScroller:Provider>
    </div>

    {# Hebrew #}
    <div dir="rtl" class="">
        <twig:MessageScroller:Provider>
            <twig:Card class="">
                <twig:Card:Header class="">
                    <twig:Card:Title>שיחה חדשה</twig:Card:Title>
                    <twig:Card:Description>איך אפשר לעזור היום?</twig:Card:Description>
                </twig:Card:Header>
                <twig:Card:Content class="">
                    <twig:MessageScroller>
                        <twig:MessageScroller:Viewport>
                            <twig:MessageScroller:Content class="">
                                <twig:MessageScroller:Item messageId="rtl-he-1" scrollAnchor>
                                    <twig:Message align="end">
                                        <twig:Message:Content>
                                            <twig:Bubble variant="muted">
                                                <twig:Bubble:Content>למה השיחה קופצת בכל פעם שמגיעה תשובה חדשה?</twig:Bubble:Content>
                                            </twig:Bubble>
                                        </twig:Message:Content>
                                    </twig:Message>
                                </twig:MessageScroller:Item>
                                <twig:MessageScroller:Item messageId="rtl-he-2">
                                    <twig:Message>
                                        <twig:Message:Content>
                                            <twig:Bubble variant="ghost">
                                                <twig:Bubble:Content>כי המכל גולל למטה עם כל חלק חדש. MessageScroller מצמיד את התור החדש קרוב לראש, ועוקב אחרי התשובה רק כשהקורא נמצא בסוף.</twig:Bubble:Content>
                                            </twig:Bubble>
                                        </twig:Message:Content>
                                    </twig:Message>
                                </twig:MessageScroller:Item>
                                <twig:MessageScroller:Item messageId="rtl-he-3" scrollAnchor>
                                    <twig:Message align="end">
                                        <twig:Message:Content>
                                            <twig:Bubble variant="muted">
                                                <twig:Bubble:Content>ואם גללתי למעלה כדי לקרוא הודעה ישנה?</twig:Bubble:Content>
                                            </twig:Bubble>
                                        </twig:Message:Content>
                                    </twig:Message>
                                </twig:MessageScroller:Item>
                                <twig:MessageScroller:Item messageId="rtl-he-4">
                                    <twig:Message>
                                        <twig:Message:Content>
                                            <twig:Bubble variant="ghost">
                                                <twig:Bubble:Content>המקום שלך נשמר. כפתור מופיע בתחתית התצוגה כדי לחזור להודעה האחרונה מתי שתרצה.</twig:Bubble:Content>
                                            </twig:Bubble>
                                        </twig:Message:Content>
                                    </twig:Message>
                                </twig:MessageScroller:Item>
                            </twig:MessageScroller:Content>
                        </twig:MessageScroller:Viewport>
                        <twig:MessageScroller:Button />
                    </twig:MessageScroller>
                </twig:Card:Content>
            </twig:Card>
        </twig:MessageScroller:Provider>
    </div>
</div>

Accessibility

  • MessageScroller:Viewport is a labelled region the keyboard can reach: it renders role="region", aria-label="Messages" and tabindex="0", so keyboard users can focus the transcript and scroll it. Pass another aria-label to name it after the conversation.
  • MessageScroller:Content is a live region, with role="log" and aria-relevant="additions": new rows are announced, but streamed text is not read out chunk by chunk. Set aria-busy="true" while a reply streams, so the announcement waits for the complete row.
  • MessageScroller:Button renders a real <button> with a visually hidden label. While there is nothing to scroll toward, it is inert, has tabindex="-1" and data-active="false", so it adds no focus stop.
  • Scrolling with the wheel, a touch gesture, the scrollbar or the keys of the viewport (arrows, Page Up, Page Down, Home, End and Space) stops the following, so the transcript never moves against the reader.

API Reference

<twig:MessageScroller>

Block Description
content A MessageScroller:Viewport and a MessageScroller:Button.

<twig:MessageScroller:Button>

Prop Type Default
direction 
'start'|'end' 'end'
behavior 
'auto'|'smooth' 'smooth'
variant 
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' 'secondary'
size 
'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' 'icon-sm'
Block Description
content The button content, an arrow and a visually hidden label by default.

<twig:MessageScroller:Content>

Block Description
content The MessageScroller:Item rows of the transcript.

<twig:MessageScroller:Item>

Prop Type Default
messageId 
string|null null
scrollAnchor 
boolean false
Block Description
content A message, a marker, a typing indicator or any other row of the transcript.

<twig:MessageScroller:Provider>

Prop Type Default
autoScroll 
boolean false
defaultScrollPosition 
'start'|'end'|'last-anchor' 'end'
scrollEdgeThreshold 
int 8
scrollPreviousItemPeek 
int 64
scrollMargin 
int 0
Block Description
content A MessageScroller, and any control that drives it, such as a button calling scrollToMessage.

<twig:MessageScroller:Viewport>

Prop Type Default
preserveScrollOnPrepend 
boolean true
Block Description
content A MessageScroller:Content.

data-controller="message-scroller"

Value Type Default
data-message-scroller-auto-scroll-value 
Boolean -
data-message-scroller-default-scroll-position-value 
String 'end'
data-message-scroller-scroll-edge-threshold-value 
Number 8
data-message-scroller-scroll-previous-item-peek-value 
Number 64
data-message-scroller-scroll-margin-value 
Number 0
Target Description
root The frame, which reflects the scroll state like the viewport.
viewport The scrollable element.
content The element holding the rows of the transcript.
spacer The empty row that gives a newly anchored turn room to settle near the top.
button A button scrolling to one edge, active only while there is content in its direction.
anchorLink A control calling scrollToMessage, marked with aria-current while its row is the current turn.
Action Description
scrollToStart Scrolls to the start of the transcript.
scrollToEnd Scrolls to the end of the transcript, and follows new content again when autoScroll is on.
scrollToMessage Scrolls to the row whose messageId is the id param, aligned with the align param.