A chat transcript has to juggle several things at once: pin to the live edge while a reply streams in, without fighting a reader who scrolls up; move each new turn near the top so it reads from its beginning; keep the reading position steady when older history loads above; and jump to any message on demand. MessageScroller owns those parts so your message list does not have to.
The family is a set of parts you compose:
Part
Role
NMessageScrollerProvider
Owns the scroll engine. Renders no markup.
NMessageScroller
Positioning root for the viewport and the scroll buttons.
NMessageScrollerViewport
The scroll container itself.
NMessageScrollerContent
The message list, plus the spacer that anchoring grows into.
NMessageScrollerItem
One message. Carries its id and whether it anchors a turn.
NMessageScrollerButton
A floating jump-to-edge button that shows itself only when it can act.
Wrap the transcript in a provider, put the messages inside the viewport's content, and give each one a messageId. Nothing else is required — the button appears only when there is somewhere to scroll.
Preview
Code
What does a message scroller actually have to do?
Keep the reader where they expect to be. That means following a reply while it streams, but never fighting someone who scrolls up to re-read something.
And when older history loads above?
The reading position is preserved — the message you were looking at stays exactly where it was, instead of being pushed down the page.
What about jumping around the thread?
Every item can carry a messageId, so you can scroll to any of them on demand, with the alignment you want.
<script setup lang="ts">
const messages = [
{ id: 'm1', role: 'user', text: 'What does a message scroller actually have to do?' },
{ id: 'm2', role: 'assistant', text: 'Keep the reader where they expect to be. That means following a reply while it streams, but never fighting someone who scrolls up to re-read something.' },
{ id: 'm3', role: 'user', text: 'And when older history loads above?' },
{ id: 'm4', role: 'assistant', text: 'The reading position is preserved — the message you were looking at stays exactly where it was, instead of being pushed down the page.' },
{ id: 'm5', role: 'user', text: 'What about jumping around the thread?' },
{ id: 'm6', role: 'assistant', text: 'Every item can carry a messageId, so you can scroll to any of them on demand, with the alignment you want.' },
]
</script>
<template>
<div class="h-80 w-full border rounded-lg bg-background">
<NMessageScrollerProvider>
<NMessageScroller>
<NMessageScrollerViewport>
<NMessageScrollerContent class="p-4">
<NMessageScrollerItem
v-for="message in messages"
:key="message.id"
:message-id="message.id"
>
<div
class="max-w-[80%] rounded-lg px-3 py-2 text-sm"
:class="message.role === 'user'
? 'ml-auto bg-primary text-primary-foreground'
: 'bg-muted text-foreground'"
>
{{ message.text }}
</div>
</NMessageScrollerItem>
</NMessageScrollerContent>
</NMessageScrollerViewport>
<NMessageScrollerButton />
</NMessageScroller>
</NMessageScrollerProvider>
</div>
</template>
autoScroll keeps a streaming reply in view as it grows. Scrolling toward the start — by wheel, touch or keyboard — releases the view, so the following chunks arrive without moving the reader. Returning to the live edge picks the thread back up.
Prop
Default
Type
Description
autoScroll
false
boolean
Follow the live edge while the reader is sitting at it.
Mutate the streaming message through the array proxy (messages.value[messages.value.length - 1]), not through a raw object you captured earlier. Assigning the same string back through a stale reference does not trigger reactivity, and the reply will appear to never grow.
Preview
Code
Stream me something long enough to scroll.
While a reply streams, the viewport stays pinned to the live edge. Scroll up and it lets go — new chunks arrive without moving you. Come back to the bottom and it picks the thread up again.
<script setup lang="ts">
interface Message {
id: string
role: 'user' | 'assistant'
text: string
}
const REPLY = 'While a reply streams, the viewport stays pinned to the live edge. Scroll up and it lets go — new chunks arrive without moving you. Come back to the bottom and it picks the thread up again.'
const messages = ref<Message[]>([
{ id: 'm1', role: 'user', text: 'Stream me something long enough to scroll.' },
{ id: 'm2', role: 'assistant', text: REPLY },
])
const streaming = ref(false)
let timer: ReturnType<typeof setInterval> | null = null
let uid = 2
function stop() {
if (timer) {
clearInterval(timer)
timer = null
}
streaming.value = false
}
function stream() {
if (streaming.value)
return
uid += 1
messages.value.push({ id: `m${uid}`, role: 'user', text: 'Once more, please.' })
uid += 1
messages.value.push({ id: `m${uid}`, role: 'assistant', text: '' })
// mutate through the array proxy so every chunk triggers reactivity
const reply = messages.value[messages.value.length - 1]!
streaming.value = true
const words = `${REPLY} ${REPLY}`.split(' ')
let index = 0
timer = setInterval(() => {
if (index >= words.length) {
stop()
return
}
reply.text += `${words[index]} `
index += 1
}, 70)
}
onBeforeUnmount(stop)
</script>
<template>
<div class="w-full flex flex-col gap-3">
<NButton
btn="outline-gray"
size="sm"
label="Stream a reply"
class="self-start"
:disabled="streaming"
@click="stream"
/>
<div class="h-80 border rounded-lg bg-background">
<NMessageScrollerProvider auto-scroll>
<NMessageScroller>
<NMessageScrollerViewport>
<NMessageScrollerContent class="p-4">
<NMessageScrollerItem
v-for="message in messages"
:key="message.id"
:message-id="message.id"
>
<div
class="max-w-[80%] rounded-lg px-3 py-2 text-sm"
:class="message.role === 'user'
? 'ml-auto bg-primary text-primary-foreground'
: 'bg-muted text-foreground'"
>
{{ message.text }}
</div>
</NMessageScrollerItem>
</NMessageScrollerContent>
</NMessageScrollerViewport>
<NMessageScrollerButton />
</NMessageScroller>
</NMessageScrollerProvider>
</div>
</div>
</template>
A turn is a new exchange — usually a question and the reply that follows it. Mark the row that starts it with scrollAnchor, and the viewport moves that row near the top when it arrives, keeping a peek of the previous exchange above it so the turn does not feel detached.
Anchoring is role-independent: a system marker or a "joined the chat" row can anchor a turn just as well as a user message.
Prop
Default
Type
Description
scrollAnchor
false
boolean
Marks the item as the start of a turn. Set on NMessageScrollerItem.
defaultScrollPosition
end
start, end, last-anchor
Where the viewport opens. Set on the provider.
scrollPreviousItemPeek
64
number
Pixels of the previous item kept visible above an anchored turn.
Preview
Code
First question of the thread.
And the answer to it, which is long enough to take up a bit of room in the transcript so there is something to scroll past.
A follow-up question.
Another answer. Each user turn is marked as a scroll anchor, so a new turn is moved to the top of the viewport with a peek of the previous exchange left above it.
<script setup lang="ts">
interface Message {
id: string
role: 'user' | 'assistant'
text: string
}
const messages = ref<Message[]>([
{ id: 'm1', role: 'user', text: 'First question of the thread.' },
{ id: 'm2', role: 'assistant', text: 'And the answer to it, which is long enough to take up a bit of room in the transcript so there is something to scroll past.' },
{ id: 'm3', role: 'user', text: 'A follow-up question.' },
{ id: 'm4', role: 'assistant', text: 'Another answer. Each user turn is marked as a scroll anchor, so a new turn is moved to the top of the viewport with a peek of the previous exchange left above it.' },
])
let uid = 4
function askAgain() {
uid += 1
messages.value.push({ id: `m${uid}`, role: 'user', text: `Question ${Math.ceil(uid / 2)} — watch this one move to the top.` })
uid += 1
messages.value.push({ id: `m${uid}`, role: 'assistant', text: 'The reply lands underneath the anchored question, so the turn reads from its beginning instead of appearing halfway up the viewport.' })
}
</script>
<template>
<div class="w-full flex flex-col gap-3">
<NButton btn="outline-gray" size="sm" label="Ask a new question" class="self-start" @click="askAgain" />
<div class="h-80 border rounded-lg bg-background">
<NMessageScrollerProvider auto-scroll default-scroll-position="last-anchor">
<NMessageScroller>
<NMessageScrollerViewport>
<NMessageScrollerContent class="p-4">
<NMessageScrollerItem
v-for="message in messages"
:key="message.id"
:message-id="message.id"
:scroll-anchor="message.role === 'user'"
>
<div
class="max-w-[80%] rounded-lg px-3 py-2 text-sm"
:class="message.role === 'user'
? 'ml-auto bg-primary text-primary-foreground'
: 'bg-muted text-foreground'"
>
{{ message.text }}
</div>
</NMessageScrollerItem>
</NMessageScrollerContent>
</NMessageScrollerViewport>
<NMessageScrollerButton />
</NMessageScroller>
</NMessageScrollerProvider>
</div>
</div>
</template>
When messages are added above the reader, the scroller restores the position of the first visible message afterwards, so the transcript grows upward without the page jumping. Turn it off with preserveScrollOnPrepend on the viewport.
Prop
Default
Type
Description
preserveScrollOnPrepend
true
boolean
Hold the reading position when items are added above it.
Preview
Code
Scroll to the top and load the history above.
The scroller records where the first visible message sits, then puts it back after the prepend — so the transcript grows upward without the page jumping under you.
Try it a few times in a row.
Each page of history is added above, and your reading position stays put to the pixel.
<script setup lang="ts">
interface Message {
id: string
role: 'user' | 'assistant'
text: string
}
let oldest = 0
function olderPage(): Message[] {
return Array.from({ length: 4 }, (_, i) => {
oldest -= 1
return {
id: `old${oldest}`,
role: (i % 2 === 0 ? 'user' : 'assistant') as Message['role'],
text: `Older message ${oldest}. Loading history above the reader must not move the message they are reading.`,
}
}).reverse()
}
const messages = ref<Message[]>([
{ id: 'm1', role: 'user', text: 'Scroll to the top and load the history above.' },
{ id: 'm2', role: 'assistant', text: 'The scroller records where the first visible message sits, then puts it back after the prepend — so the transcript grows upward without the page jumping under you.' },
{ id: 'm3', role: 'user', text: 'Try it a few times in a row.' },
{ id: 'm4', role: 'assistant', text: 'Each page of history is added above, and your reading position stays put to the pixel.' },
])
function loadOlder() {
messages.value.unshift(...olderPage())
}
</script>
<template>
<div class="w-full flex flex-col gap-3">
<NButton btn="outline-gray" size="sm" label="Load older messages" class="self-start" @click="loadOlder" />
<div class="h-80 border rounded-lg bg-background">
<NMessageScrollerProvider>
<NMessageScroller>
<NMessageScrollerViewport>
<NMessageScrollerContent class="p-4">
<NMessageScrollerItem
v-for="message in messages"
:key="message.id"
:message-id="message.id"
>
<div
class="max-w-[80%] rounded-lg px-3 py-2 text-sm"
:class="message.role === 'user'
? 'ml-auto bg-primary text-primary-foreground'
: 'bg-muted text-foreground'"
>
{{ message.text }}
</div>
</NMessageScrollerItem>
</NMessageScrollerContent>
</NMessageScrollerViewport>
<NMessageScrollerButton direction="start" />
<NMessageScrollerButton />
</NMessageScroller>
</NMessageScrollerProvider>
</div>
</div>
</template>
NMessageScrollerButton wraps NButton, so every button prop passes through. It hides itself — and goes inert — whenever its direction has nowhere to go, and its icon flips for direction="start".
Prop
Default
Type
Description
direction
end
start, end
Which edge the button scrolls to.
behavior
smooth
auto, smooth
Scroll behaviour used on click.
btn
outline-white
string
Any button variant.
Prefer an opaque variant. The button floats over the transcript, so translucent variants such as soft and ghost let the messages underneath read through it.
Inside the provider, useMessageScroller() exposes the scroll commands and useMessageScrollerVisibility() reports which messages are on screen and which turn the reader is in. Both read the provider's context, so call them from a component rendered insideNMessageScrollerProvider. A control that sits outside that subtree reaches the scroll commands through a template ref instead — see Expose.
{ start, end } — whether either edge can still be scrolled to.
useMessageScrollerVisibility()
{ currentAnchorId, visibleMessageIds }.
scrollToMessage takes an align of start, center, end or nearest, plus behavior and scrollMargin.
Preview
Code
Question 1 — the anchor for this turn.
Answer 1. Long enough that the turns do not all fit at once, so jumping between them actually moves the viewport and the visibility state changes as you go.
Question 2 — the anchor for this turn.
Answer 2. Long enough that the turns do not all fit at once, so jumping between them actually moves the viewport and the visibility state changes as you go.
Question 3 — the anchor for this turn.
Answer 3. Long enough that the turns do not all fit at once, so jumping between them actually moves the viewport and the visibility state changes as you go.
Question 4 — the anchor for this turn.
Answer 4. Long enough that the turns do not all fit at once, so jumping between them actually moves the viewport and the visibility state changes as you go.
Question 5 — the anchor for this turn.
Answer 5. Long enough that the turns do not all fit at once, so jumping between them actually moves the viewport and the visibility state changes as you go.
current turn: — · visible: 0
<script setup lang="ts">
const messages = Array.from({ length: 5 }, (_, turn) => [
{
id: `q${turn + 1}`,
role: 'user' as const,
text: `Question ${turn + 1} — the anchor for this turn.`,
},
{
id: `a${turn + 1}`,
role: 'assistant' as const,
text: `Answer ${turn + 1}. Long enough that the turns do not all fit at once, so jumping between them actually moves the viewport and the visibility state changes as you go.`,
},
]).flat()
const targets = Array.from({ length: 5 }, (_, turn) => ({
id: `q${turn + 1}`,
label: `Turn ${turn + 1}`,
}))
</script>
<template>
<NMessageScrollerProvider default-scroll-position="start">
<div class="w-full flex flex-col gap-3">
<div class="h-80 border rounded-lg bg-background">
<NMessageScroller>
<NMessageScrollerViewport>
<NMessageScrollerContent class="p-4">
<NMessageScrollerItem
v-for="message in messages"
:key="message.id"
:message-id="message.id"
:scroll-anchor="message.role === 'user'"
>
<div
class="max-w-[80%] rounded-lg px-3 py-2 text-sm"
:class="message.role === 'user'
? 'ml-auto bg-primary text-primary-foreground'
: 'bg-muted text-foreground'"
>
{{ message.text }}
</div>
</NMessageScrollerItem>
</NMessageScrollerContent>
</NMessageScrollerViewport>
<NMessageScrollerButton direction="start" />
<NMessageScrollerButton />
</NMessageScroller>
</div>
<ExampleVueMessageScrollerJumpControls :targets="targets" />
</div>
</NMessageScrollerProvider>
</template>
<script setup lang="ts">
// The composables read the scroller's context, so they have to be called from a
// component rendered inside NMessageScrollerProvider — not from the component
// that renders the provider itself.
defineProps<{
targets: { id: string, label: string }[]
}>()
const { scrollToMessage } = useMessageScroller()
const visibility = useMessageScrollerVisibility()
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<NButton
v-for="target in targets"
:key="target.id"
btn="outline-gray"
size="sm"
:label="target.label"
@click="scrollToMessage(target.id, { align: 'start' })"
/>
<span class="ml-auto text-xs text-muted-foreground">
current turn: <code>{{ visibility.currentAnchorId ?? '—' }}</code>
· visible: <code>{{ visibility.visibleMessageIds.length }}</code>
</span>
</div>
</template>
The viewport ships with the scroll-fade and scrollbar utilities applied: its bottom edge fades while there is more to read, the scrollbar is thin, and the scrollbar hides itself while the viewport is autoscrolling. Every part takes class and a matching una key, and the parts expose data-slot, data-scrollable and data-autoscrolling for styling from the outside.
useMessageScroller() only reaches the context from inside the provider. A composer or toolbar rendered as a sibling of the transcript — or the component that renders NMessageScrollerProvider in the first place — has nothing to inject, so NMessageScroller exposes the same three commands on its instance.
Each returns false when the viewport is not mounted yet. scrollToMessage queues the jump when the message has not registered yet, so it is safe to call before the item renders — scrollToEnd is immediate, so await nextTick() after appending a message.
Preview
Code
Can a composer outside the provider still scroll the transcript?
Yes — through a template ref. The provider puts the context below itself, so anything rendered as a sibling of the transcript has nothing to inject, and this composer is rendered by the same component that renders the provider.
What does the ref give me?
The same three commands useMessageScroller() returns — scrollToEnd, scrollToStart and scrollToMessage. NMessageScroller exposes them on its instance, so the context is not the only way in.
And the send button?
Append the message, await nextTick() so the new item is in the DOM, then call scrollToEnd. Without the tick you would scroll to where the end used to be.
Does Top work the same way?
It calls scrollToStart off the same ref. Send a few messages, then jump back up here to see both ends of the transcript move.
<script setup lang="ts">
// This composer is rendered by the same component that renders the provider, so
// it sits outside the context and cannot call useMessageScroller(). It drives
// the transcript through a template ref on NMessageScroller instead.
const scroller = useTemplateRef('scroller')
const messages = ref([
{ id: 'm1', role: 'user', text: 'Can a composer outside the provider still scroll the transcript?' },
{ id: 'm2', role: 'assistant', text: 'Yes — through a template ref. The provider puts the context below itself, so anything rendered as a sibling of the transcript has nothing to inject, and this composer is rendered by the same component that renders the provider.' },
{ id: 'm3', role: 'user', text: 'What does the ref give me?' },
{ id: 'm4', role: 'assistant', text: 'The same three commands useMessageScroller() returns — scrollToEnd, scrollToStart and scrollToMessage. NMessageScroller exposes them on its instance, so the context is not the only way in.' },
{ id: 'm5', role: 'user', text: 'And the send button?' },
{ id: 'm6', role: 'assistant', text: 'Append the message, await nextTick() so the new item is in the DOM, then call scrollToEnd. Without the tick you would scroll to where the end used to be.' },
{ id: 'm7', role: 'user', text: 'Does Top work the same way?' },
{ id: 'm8', role: 'assistant', text: 'It calls scrollToStart off the same ref. Send a few messages, then jump back up here to see both ends of the transcript move.' },
])
const draft = ref('')
async function send() {
const text = draft.value.trim()
if (!text)
return
messages.value.push({ id: `m${messages.value.length + 1}`, role: 'user', text })
draft.value = ''
// The append has to be in the DOM before the new end can be scrolled to.
await nextTick()
scroller.value?.scrollToEnd({ behavior: 'smooth' })
}
</script>
<template>
<div class="w-full flex flex-col gap-3">
<div class="h-80 border rounded-lg bg-background">
<NMessageScrollerProvider>
<NMessageScroller ref="scroller">
<NMessageScrollerViewport>
<NMessageScrollerContent class="p-4">
<NMessageScrollerItem
v-for="message in messages"
:key="message.id"
:message-id="message.id"
>
<div
class="max-w-[80%] rounded-lg px-3 py-2 text-sm"
:class="message.role === 'user'
? 'ml-auto bg-primary text-primary-foreground'
: 'bg-muted text-foreground'"
>
{{ message.text }}
</div>
</NMessageScrollerItem>
</NMessageScrollerContent>
</NMessageScrollerViewport>
<NMessageScrollerButton />
</NMessageScroller>
</NMessageScrollerProvider>
</div>
<div class="flex items-center gap-2">
<NInput
v-model="draft"
placeholder="Write a message, then send"
:una="{ inputWrapper: 'flex-1' }"
@keydown.enter="send"
/>
<NButton
btn="outline-gray"
size="sm"
label="Send"
@click="send"
/>
<NButton
btn="outline-gray"
size="sm"
label="Top"
@click="scroller?.scrollToStart({ behavior: 'smooth' })"
/>
</div>
</div>
</template>
import type { HTMLAttributes } from 'vue'
import type { NButtonProps } from './button'
/**
* Where the viewport is placed on mount.
*/
export type NMessageScrollerDefaultScrollPosition = 'start' | 'end' | 'last-anchor'
/**
* The edge a scroll button jumps to.
*/
export type NMessageScrollerButtonDirection = 'start' | 'end'
/**
* How a message is aligned once it is scrolled into view.
*/
export type NMessageScrollerScrollAlign = 'start' | 'center' | 'end' | 'nearest'
export interface NMessageScrollerScrollOptions {
/**
* Where the message lands inside the viewport.
*
* @default 'start'
*/
align?: NMessageScrollerScrollAlign
/**
* The scroll behavior.
*
* @default 'smooth'
*/
behavior?: ScrollBehavior
/**
* Extra space left between the message and the viewport edge, in pixels.
*/
scrollMargin?: number
}
/**
* Which directions the viewport can still be scrolled in.
*/
export interface NMessageScrollerScrollable {
start: boolean
end: boolean
}
/**
* Which messages the viewport currently shows.
*/
export interface NMessageScrollerVisibilityState {
currentAnchorId: string | null
visibleMessageIds: string[]
}
export interface NMessageScrollerProps {
/**
* Additional classes to apply to the scroller.
*/
class?: HTMLAttributes['class']
/**
* `UnaUI` preset configuration
*
* @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/message-scroller.ts
*/
una?: Pick<NMessageScrollerUnaProps, 'messageScroller'>
}
export interface NMessageScrollerProviderProps {
/**
* Follow new content as it arrives, keeping the viewport pinned to the end
* until the reader scrolls away.
*
* @default false
*/
autoScroll?: boolean
/**
* Where the viewport is placed on mount.
*
* @default 'end'
*/
defaultScrollPosition?: NMessageScrollerDefaultScrollPosition
/**
* How close to an edge, in pixels, still counts as being at that edge.
*
* @default 8
*/
scrollEdgeThreshold?: number
/**
* How much of the previous message stays visible above an anchored message,
* in pixels.
*
* @default 64
*/
scrollPreviousItemPeek?: number
/**
* Extra space left between a scrolled-to message and the viewport edge,
* in pixels.
*
* @default 0
*/
scrollMargin?: number
}
export interface NMessageScrollerViewportProps {
/**
* Additional classes to apply to the viewport.
*/
class?: HTMLAttributes['class']
/**
* Keep the reading position steady when messages are added above the
* current scroll position.
*
* @default true
*/
preserveScrollOnPrepend?: boolean
/**
* `UnaUI` preset configuration
*
* @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/message-scroller.ts
*/
una?: Pick<NMessageScrollerUnaProps, 'messageScrollerViewport'>
}
export interface NMessageScrollerContentProps {
/**
* Additional classes to apply to the content list.
*/
class?: HTMLAttributes['class']
/**
* Additional classes to apply to the trailing spacer that anchoring grows
* and consumes.
*/
spacerClass?: HTMLAttributes['class']
/**
* `UnaUI` preset configuration
*
* @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/message-scroller.ts
*/
una?: Pick<NMessageScrollerUnaProps, 'messageScrollerContent'>
}
export interface NMessageScrollerItemProps {
/**
* Identifies the message, enabling `scrollToMessage` and visibility
* tracking. Items without an id are laid out but not tracked.
*/
messageId?: string
/**
* Mark the message as an anchor, so the scroller pins it to the top of the
* viewport as its content grows.
*
* @default false
*/
scrollAnchor?: boolean
/**
* Additional classes to apply to the item.
*/
class?: HTMLAttributes['class']
/**
* `UnaUI` preset configuration
*
* @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/message-scroller.ts
*/
una?: Pick<NMessageScrollerUnaProps, 'messageScrollerItem'>
}
export interface NMessageScrollerButtonProps extends Omit<NButtonProps, 'una'> {
/**
* The edge the button scrolls to.
*
* @default 'end'
*/
direction?: NMessageScrollerButtonDirection
/**
* The scroll behavior used when the button is clicked.
*
* @default 'smooth'
*/
behavior?: ScrollBehavior
/**
* Additional classes to apply to the button.
*/
class?: HTMLAttributes['class']
/**
* `UnaUI` preset configuration
*
* @see https://github.com/una-ui/una-ui/blob/main/packages/preset/src/_shortcuts/message-scroller.ts
*/
una?: Pick<NMessageScrollerUnaProps, 'messageScrollerButton'> & NButtonProps['una']
}
/**
* UnaUI preset configuration for message scroller components
*/
export interface NMessageScrollerUnaProps {
messageScroller?: HTMLAttributes['class']
messageScrollerViewport?: HTMLAttributes['class']
messageScrollerContent?: HTMLAttributes['class']
messageScrollerItem?: HTMLAttributes['class']
messageScrollerButton?: HTMLAttributes['class']
}
<script setup lang="ts">
import type { NMessageScrollerProviderProps } from '../../types'
import { provideMessageScroller } from './useMessageScroller'
const props = defineProps<NMessageScrollerProviderProps>()
provideMessageScroller(props)
</script>
<template>
<slot />
</template>
<script setup lang="ts">
import type { NMessageScrollerProps } from '../../types'
import { cn } from '../../utils'
import { useMessageScrollerContext } from './useMessageScroller'
const props = defineProps<NMessageScrollerProps>()
const { autoscrolling, scrollableAttr, scrollToEnd, scrollToMessage, scrollToStart } = useMessageScrollerContext()
/**
* Exposed for controls the provider cannot reach by injection — a composer or
* toolbar rendered as a sibling of this subtree, or in the setup scope that
* renders `NMessageScrollerProvider`, cannot inject a context provided below it.
*
* Deliberate divergence from the shadcn-vue source, which exposes nothing here
* — keep it when diffing against upstream. See #645.
*/
defineExpose({ scrollToEnd, scrollToMessage, scrollToStart })
</script>
<template>
<div
data-slot="message-scroller"
:data-scrollable="scrollableAttr"
:data-autoscrolling="autoscrolling ? '' : undefined"
:class="cn(
'group/message-scroller message-scroller',
props.una?.messageScroller,
props.class,
)"
>
<slot />
</div>
</template>
<script setup lang="ts">
import type { NMessageScrollerViewportProps } from '../../types'
import { onBeforeUnmount, onMounted, useTemplateRef, watch } from 'vue'
import { cn } from '../../utils'
import { isScrollTowardStartKey, SCROLL_KEYS, useMessageScrollerContext } from './useMessageScroller'
const props = withDefaults(defineProps<NMessageScrollerViewportProps>(), {
preserveScrollOnPrepend: true,
})
const {
autoscrolling,
handleResize,
scrollableAttr,
setPreserveScrollOnPrepend,
setViewportElement,
syncAfterScroll,
userScrollIntent,
} = useMessageScrollerContext()
const viewportEl = useTemplateRef<HTMLElement>('viewport')
watch(() => props.preserveScrollOnPrepend, setPreserveScrollOnPrepend, { immediate: true })
// Only a gesture that carries the reader toward the start releases autoScroll:
// wheeling or paging down while already at the live edge moves nothing.
function onWheel(event: WheelEvent) {
userScrollIntent(event.deltaY < 0)
}
let touchStartY: number | null = null
function onTouchStart(event: TouchEvent) {
touchStartY = event.touches[0]?.clientY ?? null
}
function onTouchMove(event: TouchEvent) {
const y = event.touches[0]?.clientY ?? null
// dragging the finger down pulls the transcript toward the start
userScrollIntent(touchStartY === null || y === null ? true : y > touchStartY)
}
function onKeyDown(event: KeyboardEvent) {
if (SCROLL_KEYS.has(event.key))
userScrollIntent(isScrollTowardStartKey(event))
}
let resizeObserver: ResizeObserver | null = null
let resizeFrame = 0
onMounted(() => {
const viewport = viewportEl.value
setViewportElement(viewport)
if (!viewport || typeof ResizeObserver === 'undefined')
return
resizeObserver = new ResizeObserver(() => {
window.cancelAnimationFrame(resizeFrame)
resizeFrame = window.requestAnimationFrame(handleResize)
})
resizeObserver.observe(viewport)
})
onBeforeUnmount(() => {
window.cancelAnimationFrame(resizeFrame)
resizeObserver?.disconnect()
resizeObserver = null
setViewportElement(null)
})
</script>
<template>
<div
ref="viewport"
data-slot="message-scroller-viewport"
role="region"
aria-label="Messages"
:tabindex="0"
:data-scrollable="scrollableAttr"
:data-autoscrolling="autoscrolling ? '' : undefined"
:class="cn(
'message-scroller-viewport',
props.una?.messageScrollerViewport,
props.class,
)"
@scroll="syncAfterScroll()"
@wheel="onWheel"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@keydown="onKeyDown"
>
<slot />
</div>
</template>