AI Conversation
Auto-scroll is the most common way a chat interface becomes unusable. A model streams an answer, the reader scrolls up to re-read the part that mattered, and the container drags them back to the bottom on the next token. The intent of that scroll was explicit - they wanted to read something - and the interface overruled it.
So this container follows the bottom only while the reader is already there. Scrolling up unpins it. When it is unpinned, a pill appears at the bottom with a single job: take you back. Nothing moves on its own after that.
It also covers growth that is not a new message: an image finishing loading, a
details element opening, a font swapping in. A ResizeObserver watches the
children, and contentKey is the separate signal for content that grows in place,
like a streamed paragraph.
Usage
Give the container a height and pass the children. Update contentKey whenever
the thread grows - the message count, or the length of the text being streamed -
so the follow knows when to run.
Agent
The audit is done. Every theme token resolves, except two in the sidebar.
You
Which two?
Agent
The category icons. They are referenced as `i-lucide-*` names that the icon collection cannot resolve.
You
Pre-existing?
Agent
Yes. They are on the components index, not on any page you added.
You
Then leave them and note it.
Agent
Noted. The rest of the run is clean: 182 pages, no drifted snippets, no missing examples.
You
Good. What is left?
<template>
<div class="w-full max-w-md">
<MAiConversation
class="h-80 rounded-xl border border-default bg-default"
:content-key="messages.length"
>
<div class="space-y-3 p-4">
<div
v-for="message in messages"
:key="message.id"
>
<p class="text-[10px] font-medium uppercase tracking-wide text-muted">
{{ message.from }}
</p>
<p class="text-sm leading-relaxed text-default">
{{ message.text }}
</p>
</div>
</div>
</MAiConversation>
<div class="mt-2 flex items-center gap-2">
<UButton
size="xs"
@click="add"
>
New message
</UButton>
<span class="text-xs text-muted">Scroll up, then add one.</span>
</div>
</div>
</template>
<script setup lang="ts">
type Turn = { id: number, from: string, text: string }
const seed = [
['Agent', 'The audit is done. Every theme token resolves, except two in the sidebar.'],
['You', 'Which two?'],
['Agent', 'The category icons. They are referenced as `i-lucide-*` names that the icon collection cannot resolve.'],
['You', 'Pre-existing?'],
['Agent', 'Yes. They are on the components index, not on any page you added.'],
['You', 'Then leave them and note it.'],
['Agent', 'Noted. The rest of the run is clean: 182 pages, no drifted snippets, no missing examples.'],
['You', 'Good. What is left?'],
]
const messages = ref<Turn[]>(
seed.map(([from, text], index) => ({ id: index, from, text })),
)
let next = seed.length
/**
* Appending is the only thing that changes `content-key`. If the reader has
* scrolled up, the component leaves them there and shows the pill.
*/
const add = () => {
messages.value.push({
id: next,
from: next % 2 === 0 ? 'You' : 'Agent',
text: `Message ${next + 1}. The thread grows, and the container only follows if you are at the bottom.`,
})
next += 1
}
</script>
Customize
The audit is done. Every theme token resolves, except two in the sidebar.
Which two?
The category icons. They are referenced as icon names the collection cannot resolve.
Pre-existing?
Yes. They are on the components index, not on any page you added.
Then leave them and note it.
Noted. The rest of the run is clean: 182 pages, no drifted snippets, no missing examples.
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
contentKey | string | number | - | Changes whenever the thread grows - message count, or the length of the text being streamed. The follow runs after the DOM has been patched, so it measures the thread as it is now rather than one message ago. |
bottomThreshold | number | 48 | How close to the bottom, in pixels, still counts as "at the bottom". Zero breaks on fractional scroll heights; a large value keeps yanking the view down while someone reads a few lines up. |
class | string | '' | Merged onto the outer box. The height belongs here - the component does not guess how tall a thread should be. |
Slots
| Slot | Props | Description |
|---|---|---|
default | - | The thread. Anything scrollable works: MAiMessage, plain paragraphs, a list
of tool calls. |
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.aiConversation in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | relative min-h-0 w-full. The min-h-0 is what lets it shrink inside a flex
column instead of pushing the composer off the page. |
viewport | - | The scrolling element, overscroll-contain so the page behind it does not
scroll when the thread reaches its end. |
jump | - | The pill's positioning box, anchored above the bottom edge. |
jumpButton | - | The UButton inside it. |
Accessibility
- The jump pill is a real
UButtonwith the same words as itsaria-label, so it is reachable by keyboard and announced as a button. - The pill only exists while unpinned, so a screen reader is never offered a control that would do nothing.
- The viewport stays in the natural tab order, so an unpinned thread can still be scrolled with the keyboard without touching the pill.
- Under
prefers-reduced-motion: reducethe follow and the jump both usebehavior: 'auto'. A smooth scroll of a long thread is exactly the kind of motion that triggers discomfort, and it is the whole content of the gesture. - Nothing announces automatically: growing the thread does not steal focus, and the pill appears silently.
Performance
- The follow is one
scrollTopercontentKeychange, not per frame. A stream that emits a token per frame still only scrolls once per token, and only while pinned. - The
ResizeObserverwatches children rather than the whole subtree, and the mutation observer exists only to re-point it when the list changes. measure()reads three layout properties and writes one boolean. The scroll handler is the only hot path, and it never touches the DOM.- The pill mounts only while unpinned, so a pinned thread carries no extra node and no animation work at all.