Message Scroller

A scroll container for chat transcripts that anchors turns, follows streamed replies, and restores prepended history.

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:

PartRole
NMessageScrollerProviderOwns the scroll engine. Renders no markup.
NMessageScrollerPositioning root for the viewport and the scroll buttons.
NMessageScrollerViewportThe scroll container itself.
NMessageScrollerContentThe message list, plus the spacer that anchoring grows into.
NMessageScrollerItemOne message. Carries its id and whether it anchors a turn.
NMessageScrollerButtonA floating jump-to-edge button that shows itself only when it can act.

Examples

Basic

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.

Following the live edge

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.

PropDefaultTypeDescription
autoScrollfalsebooleanFollow the live edge while the reader is sitting at it.
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.

Anchoring turns

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.

PropDefaultTypeDescription
scrollAnchorfalsebooleanMarks the item as the start of a turn. Set on NMessageScrollerItem.
defaultScrollPositionendstart, end, last-anchorWhere the viewport opens. Set on the provider.
scrollPreviousItemPeek64numberPixels 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.

Loading older history

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.

PropDefaultTypeDescription
preserveScrollOnPrependtruebooleanHold 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.

Scroll buttons

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".

PropDefaultTypeDescription
directionendstart, endWhich edge the button scrolls to.
behaviorsmoothauto, smoothScroll behaviour used on click.
btnoutline-whitestringAny button variant.
<NMessageScroller>
  <NMessageScrollerViewport>...</NMessageScrollerViewport>

  <NMessageScrollerButton direction="start" btn="solid-gray" />
  <NMessageScrollerButton label="i-lucide-chevrons-down" />
</NMessageScroller>

Jumping to messages

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 inside NMessageScrollerProvider. A control that sits outside that subtree reaches the scroll commands through a template ref instead — see Expose.

ComposableReturns
useMessageScroller()scrollToEnd(options?), scrollToStart(options?), scrollToMessage(id, options?)
useMessageScrollerScrollable(){ 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

Styling

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.

<NMessageScrollerViewport
  class="scroll-fade-b-16"
  :una="{ messageScrollerViewport: 'no-scrollbar' }"
/>

Expose

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.

NameTypeDescription
scrollToEnd(options?: { behavior?: ScrollBehavior }) => booleanJumps to the live edge and resumes following it if autoScroll.
scrollToStart(options?: { behavior?: ScrollBehavior }) => booleanJumps to the top of the transcript.
scrollToMessage(id: string, options?: NMessageScrollerScrollOptions) => booleanJumps to a message by its messageId.

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.

Props

types/message-scroller.ts
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']
}

Components

MessageScrollerProvider.vue
MessageScroller.vue
MessageScrollerViewport.vue
MessageScrollerContent.vue
MessageScrollerItem.vue
MessageScrollerButton.vue
<script setup lang="ts">
import type { NMessageScrollerProviderProps } from '../../types'
import { provideMessageScroller } from './useMessageScroller'

const props = defineProps<NMessageScrollerProviderProps>()

provideMessageScroller(props)
</script>

<template>
  <slot />
</template>