Skip to content
edmi-ui

Message Scroller

The chat viewport: anchors each turn, follows streaming replies and offers Jump to latest.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Yesterday
NVDAx is at 30%.
Any drift?
NVDAx is at 32%.
Any drift?
NVDAx is at 34%.
Any drift?
NVDAx is at 36%.
Any drift?
NVDAx is at 38%.
Any drift?
NVDAx is at 40%.
Any drift?
Yesterday
NVDAx is at 30%.
Any drift?
NVDAx is at 32%.
Any drift?
NVDAx is at 34%.
Any drift?
NVDAx is at 36%.
Any drift?
NVDAx is at 38%.
Any drift?
NVDAx is at 40%.
Any drift?
Yesterday
NVDAx is at 30%.
Any drift?
NVDAx is at 32%.
Any drift?
NVDAx is at 34%.
Any drift?
NVDAx is at 36%.
Any drift?
NVDAx is at 38%.
Any drift?
NVDAx is at 40%.
Any drift?
Terminal window
npx shadcn@latest add @edmi-ui/message-scroller
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/message-scroller
Terminal window
yarn dlx shadcn@latest add @edmi-ui/message-scroller
Terminal window
bunx shadcn@latest add @edmi-ui/message-scroller

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/message-scroller
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/message-scroller
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/message-scroller
Terminal window
bunx shadcn-vue@latest add @edmi-ui/message-scroller

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/message-scroller.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/message-scroller.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/message-scroller.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/message-scroller.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import { Marker, MarkerContent } from "@/components/ui/marker";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller";
<div className="h-72 w-full max-w-md rounded-xl border border-border bg-card p-3">
<MessageScrollerProvider autoScroll>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent className="gap-4 pr-2">
<Marker variant="separator">
<MarkerContent>Yesterday</MarkerContent>
</Marker>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</div>
<script setup lang="ts">
import { Bubble, BubbleContent } from "@/components/ui/bubble";
import { Marker, MarkerContent } from "@/components/ui/marker";
import { Message, MessageContent } from "@/components/ui/message";
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/components/ui/message-scroller";
</script>
<template>
<div class="h-72 w-full max-w-md rounded-xl border border-border bg-card p-3">
<MessageScrollerProvider auto-scroll>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent class="gap-4 pr-2">
<Marker variant="separator">
<MarkerContent>Yesterday</MarkerContent>
</Marker>
<MessageScrollerItem :key="i" :message-id="`m${i}`">
<Message :align="i % 2 ? 'end' : 'start'">
<MessageContent>
<Bubble :align="i % 2 ? 'end' : 'start'" :variant="i % 2 ? 'default' : 'secondary'">
<BubbleContent>{{ i % 2 ? "Any drift?" : `NVDAx is at ${30 + i}%.` }}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</MessageScrollerItem>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</div>
</template>
<script lang="ts">
import * as Marker from "$lib/components/ui/marker";
import * as MessageScroller from "$lib/components/ui/message-scroller";
</script>
<div class="h-72 w-full max-w-md rounded-xl border border-border bg-card p-3">
<MessageScroller.Provider autoScroll>
<MessageScroller.Root>
<MessageScroller.Viewport>
<MessageScroller.Content class="gap-4 pr-2">
<Marker.Root variant="separator">
<Marker.Content>Yesterday</Marker.Content>
</Marker.Root>
<!-- … -->
</MessageScroller.Content>
</MessageScroller.Viewport>
<MessageScroller.Button />
</MessageScroller.Root>
</MessageScroller.Provider>
</div>
registry/ui/message-scroller.tsx
"use client";
import {
MessageScroller as MessageScrollerPrimitive,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "@shadcn/react/message-scroller";
import { cn } from "cn";
import type * as React from "react";
import { ArrowDownIcon } from "@phosphor-icons/react";
import { Button } from "@/registry/edmi/ui/button";
function MessageScrollerProvider(
props: React.ComponentProps<typeof MessageScrollerPrimitive.Provider>,
) {
return <MessageScrollerPrimitive.Provider {...props} />;
}
function MessageScroller({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Root>) {
return (
<MessageScrollerPrimitive.Root
data-slot="message-scroller"
className={cn(
"group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden",
className,
)}
{...props}
/>
);
}
function MessageScrollerViewport({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Viewport>) {
return (
<MessageScrollerPrimitive.Viewport
data-slot="message-scroller-viewport"
className={cn(
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-[autoscrolling]:scrollbar-thumb-transparent data-[autoscrolling]:scrollbar-track-transparent data-[pending-scroll]:invisible",
className,
)}
{...props}
/>
);
}
function MessageScrollerContent({
className,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Content>) {
return (
<MessageScrollerPrimitive.Content
data-slot="message-scroller-content"
className={cn("flex h-max min-h-full flex-col gap-6", className)}
{...props}
/>
);
}
function MessageScrollerItem({
className,
scrollAnchor = false,
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Item>) {
return (
<MessageScrollerPrimitive.Item
data-slot="message-scroller-item"
scrollAnchor={scrollAnchor}
className={cn(
"min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]",
className,
)}
{...props}
/>
);
}
function MessageScrollerButton({
direction = "end",
className,
children,
render,
variant = "outline",
size = "sm",
...props
}: React.ComponentProps<typeof MessageScrollerPrimitive.Button> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<MessageScrollerPrimitive.Button
data-slot="message-scroller-button"
data-direction={direction}
data-variant={variant}
data-size={size}
direction={direction}
className={cn(
"absolute left-1/2 -translate-x-1/2 transition-[translate,scale,opacity] duration-200 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 data-[direction=start]:[&_svg]:rotate-180",
className,
)}
render={render ?? <Button variant={variant} size={size} />}
{...props}
>
{children ?? (
<>
<ArrowDownIcon
/>
{/* ✦ Edmi: labelled pill, "Jump to latest" */}
{direction === "end" ? "Jump to latest" : "Jump to start"}
</>
)}
</MessageScrollerPrimitive.Button>
);
}
export {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
};
registry/ui/message-scroller/MessageScroller.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { useMessageScrollerContext } from "./useMessageScroller"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
const { autoscrolling, scrollableAttr } = useMessageScrollerContext()
</script>
<template>
<div
data-slot="message-scroller"
:data-scrollable="scrollableAttr"
:data-autoscrolling="autoscrolling ? '' : undefined"
:class="cn(
'group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden',
props.class,
)"
>
<slot />
</div>
</template>
registry/ui/message-scroller/MessageScrollerButton.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { MessageScrollerButtonDirection } from "./useMessageScroller"
import type { ButtonVariants } from "@/registry/edmi/ui/button"
import { ArrowDownIcon } from "@lucide/vue"
import { computed } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Button } from "@/registry/edmi/ui/button"
import { useMessageScroller, useMessageScrollerScrollable } from "./useMessageScroller"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
direction?: MessageScrollerButtonDirection
behavior?: ScrollBehavior
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
}>(), {
direction: "end",
behavior: "smooth",
variant: "outline",
size: "sm",
})
const { scrollToEnd, scrollToStart } = useMessageScroller()
const scrollable = useMessageScrollerScrollable()
const active = computed(() =>
props.direction === "start" ? scrollable.value.start : scrollable.value.end)
function onClick(event: MouseEvent) {
if (!active.value)
return
const target = event.currentTarget as HTMLElement | null
target?.blur()
if (event.defaultPrevented)
return
if (props.direction === "start")
scrollToStart({ behavior: props.behavior })
else
scrollToEnd({ behavior: props.behavior })
}
</script>
<template>
<Button
data-slot="message-scroller-button"
:data-direction="direction"
:data-active="active ? 'true' : 'false'"
:variant="variant"
:size="size"
:inert="!active"
:tabindex="active ? undefined : -1"
:class="cn(
'absolute inset-s-1/2 -translate-x-1/2 transition-[translate,scale,opacity] duration-200 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',
props.class,
)"
@click="onClick"
>
<slot>
<ArrowDownIcon />
<!-- ✦ Edmi: labelled pill, "Jump to latest" -->
{{ direction === "end" ? "Jump to latest" : "Jump to start" }}
</slot>
</Button>
</template>
registry/ui/message-scroller/MessageScrollerContent.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { onBeforeUnmount, onMounted, useTemplateRef } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { useMessageScrollerContext } from "./useMessageScroller"
const props = defineProps<{
class?: HTMLAttributes["class"]
spacerClass?: HTMLAttributes["class"]
}>()
const {
handleContentChange,
handleResize,
setContentElement,
setSpacerElement,
} = useMessageScrollerContext()
const contentRef = useTemplateRef<HTMLElement>("content")
const spacerRef = useTemplateRef<HTMLElement>("spacer")
let mutationObserver: MutationObserver | null = null
let resizeObserver: ResizeObserver | null = null
let resizeFrame = 0
onMounted(() => {
const content = contentRef.value
if (!content)
return
setContentElement(content)
setSpacerElement(spacerRef.value ?? null)
handleContentChange()
if (typeof MutationObserver !== "undefined") {
mutationObserver = new MutationObserver(() => handleContentChange())
mutationObserver.observe(content, { childList: true })
}
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => {
window.cancelAnimationFrame(resizeFrame)
resizeFrame = window.requestAnimationFrame(handleResize)
})
resizeObserver.observe(content)
}
})
onBeforeUnmount(() => {
window.cancelAnimationFrame(resizeFrame)
mutationObserver?.disconnect()
resizeObserver?.disconnect()
mutationObserver = null
resizeObserver = null
setContentElement(null)
setSpacerElement(null)
})
</script>
<template>
<div
ref="content"
data-slot="message-scroller-content"
role="log"
aria-relevant="additions"
:class="cn('flex h-max min-h-full flex-col gap-6', props.class)"
>
<slot />
<div
ref="spacer"
aria-hidden="true"
data-message-scroller-spacer=""
hidden
:class="props.spacerClass"
/>
</div>
</template>
registry/ui/message-scroller/MessageScrollerItem.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { onBeforeUnmount, onMounted, useTemplateRef, watch } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { useMessageScrollerRegister } from "./useMessageScroller"
const props = withDefaults(defineProps<{
messageId?: string
scrollAnchor?: boolean
class?: HTMLAttributes["class"]
}>(), {
scrollAnchor: false,
})
const register = useMessageScrollerRegister()
const itemEl = useTemplateRef<HTMLElement>("item")
onMounted(() => {
if (props.messageId && itemEl.value)
register(props.messageId, itemEl.value, null)
})
watch(() => props.messageId, (messageId, previousMessageId) => {
const element = itemEl.value
if (!element)
return
if (previousMessageId)
register(previousMessageId, null, element)
if (messageId)
register(messageId, element, null)
})
onBeforeUnmount(() => {
if (props.messageId && itemEl.value)
register(props.messageId, null, itemEl.value)
})
</script>
<template>
<div
ref="item"
data-slot="message-scroller-item"
:data-message-id="messageId"
:data-scroll-anchor="scrollAnchor ? 'true' : 'false'"
:class="cn(
'min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]',
props.class,
)"
>
<slot />
</div>
</template>
registry/ui/message-scroller/MessageScrollerProvider.vue
<script setup lang="ts">
import type { MessageScrollerProviderProps } from "./useMessageScroller"
import { provideMessageScroller } from "./useMessageScroller"
const props = defineProps<MessageScrollerProviderProps>()
provideMessageScroller(props)
</script>
<template>
<slot />
</template>
registry/ui/message-scroller/MessageScrollerViewport.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { onBeforeUnmount, onMounted, useTemplateRef, watch } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { SCROLL_KEYS, useMessageScrollerContext } from "./useMessageScroller"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
preserveScrollOnPrepend?: boolean
}>(), {
preserveScrollOnPrepend: true,
})
const {
autoscrolling,
handleResize,
scrollableAttr,
setPreserveScrollOnPrepend,
setViewportElement,
syncAfterScroll,
userScrollIntent,
} = useMessageScrollerContext()
const viewportEl = useTemplateRef<HTMLElement>("viewport")
watch(() => props.preserveScrollOnPrepend, setPreserveScrollOnPrepend, { immediate: true })
function onKeyDown(event: KeyboardEvent) {
if (SCROLL_KEYS.has(event.key))
userScrollIntent()
}
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(
'size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-[autoscrolling]:scrollbar-thumb-transparent data-[autoscrolling]:scrollbar-track-transparent',
props.class,
)"
@scroll="syncAfterScroll()"
@wheel="userScrollIntent()"
@touchmove="userScrollIntent()"
@keydown="onKeyDown"
>
<slot />
</div>
</template>
registry/ui/message-scroller/useMessageScroller.ts
import type { ComputedRef, InjectionKey, Ref, ShallowRef } from "vue";
import {
computed,
getCurrentScope,
inject,
onMounted,
onScopeDispose,
provide,
shallowRef,
watch,
} from "vue";
// -----------------------------------------------------------------------------
// Public types
// -----------------------------------------------------------------------------
export type MessageScrollerDefaultScrollPosition =
| "start"
| "end"
| "last-anchor";
export type MessageScrollerButtonDirection = "start" | "end";
export type MessageScrollerScrollAlign = "start" | "center" | "end" | "nearest";
export interface MessageScrollerScrollOptions {
align?: MessageScrollerScrollAlign;
behavior?: ScrollBehavior;
scrollMargin?: number;
}
export interface MessageScrollerScrollable {
start: boolean;
end: boolean;
}
export interface MessageScrollerVisibilityState {
currentAnchorId: string | null;
visibleMessageIds: string[];
}
export interface MessageScrollerProviderProps {
autoScroll?: boolean;
defaultScrollPosition?: MessageScrollerDefaultScrollPosition;
scrollEdgeThreshold?: number;
scrollPreviousItemPeek?: number;
scrollMargin?: number;
}
// -----------------------------------------------------------------------------
// Constants
// -----------------------------------------------------------------------------
const DEFAULT_SCROLL_EDGE_THRESHOLD = 8;
const DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK = 64;
const DEFAULT_SCROLL_MARGIN = 0;
const SCROLL_EPSILON = 0.5;
const AUTOSCROLLING_TIMEOUT = 180;
const SCROLL_KEYS = new Set([
"ArrowDown",
"ArrowUp",
"End",
"Home",
"PageDown",
"PageUp",
" ",
]);
const EMPTY_SCROLLABLE: MessageScrollerScrollable = {
start: false,
end: false,
};
const EMPTY_VISIBLE_IDS: string[] = [];
const EMPTY_VISIBILITY: MessageScrollerVisibilityState = {
currentAnchorId: null,
visibleMessageIds: EMPTY_VISIBLE_IDS,
};
type Mode =
| "following-bottom"
| "free-scrolling"
| "anchored-to-message"
| "settling-jump";
interface PrependRestore {
element: HTMLElement;
viewportTop: number;
}
interface PendingScrollToMessage {
messageId: string;
options?: MessageScrollerScrollOptions;
}
function scrollableEqual(
a: MessageScrollerScrollable,
b: MessageScrollerScrollable,
) {
return a.start === b.start && a.end === b.end;
}
function visibilityEqual(
a: MessageScrollerVisibilityState,
b: MessageScrollerVisibilityState,
) {
if (
a.currentAnchorId !== b.currentAnchorId ||
a.visibleMessageIds.length !== b.visibleMessageIds.length
) {
return false;
}
return a.visibleMessageIds.every(
(id, index) => id === b.visibleMessageIds[index],
);
}
// -----------------------------------------------------------------------------
// DOM measurement helpers
// -----------------------------------------------------------------------------
function parseNumber(value: string | null | undefined): number {
if (!value) return 0;
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function getPadding(element: HTMLElement): { start: number; end: number } {
const style = window.getComputedStyle(element);
return {
end: parseNumber(style.paddingBlockEnd || style.paddingBottom),
start: parseNumber(style.paddingBlockStart || style.paddingTop),
};
}
function getContentPadding(spacer: HTMLElement | null): {
start: number;
end: number;
} {
const parent = spacer?.parentElement;
return parent ? getPadding(parent) : { end: 0, start: 0 };
}
function getRowGap(element: HTMLElement | null): number {
if (!element) return 0;
const style = window.getComputedStyle(element);
const gap = style.rowGap === "normal" ? style.gap : style.rowGap;
return parseNumber(gap);
}
function getMessageChildren(
content: HTMLElement,
spacer: HTMLElement | null,
): HTMLElement[] {
return Array.from(content.children).filter(
(child): child is HTMLElement =>
child instanceof HTMLElement && child !== spacer,
);
}
function getElementOffsetTop(
element: HTMLElement,
viewport: HTMLElement,
): number {
const elementRect = element.getBoundingClientRect();
const viewportRect = viewport.getBoundingClientRect();
return elementRect.top - viewportRect.top + viewport.scrollTop;
}
function getRelativeTop(element: HTMLElement, viewport: HTMLElement): number {
return (
element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
);
}
function measureContentHeight({
content,
spacer,
viewport,
}: {
content: HTMLElement;
spacer: HTMLElement | null;
viewport: HTMLElement;
}): number {
const children = getMessageChildren(content, spacer);
const padding = getPadding(content);
const viewportRect = viewport.getBoundingClientRect();
const scrollTop = viewport.scrollTop;
let height = padding.start + padding.end;
for (const child of children) {
const rect = child.getBoundingClientRect();
height = Math.max(
height,
rect.bottom - viewportRect.top + scrollTop + padding.end,
);
}
return height;
}
function maxScrollTop(element: HTMLElement): number {
return Math.max(0, element.scrollHeight - element.clientHeight);
}
function computeSpacerHeight({
content,
scrollTop,
spacer,
viewport,
}: {
content: HTMLElement;
scrollTop: number;
spacer: HTMLElement | null;
viewport: HTMLElement;
}): number {
const contentHeight = measureContentHeight({ content, spacer, viewport });
return scrollTop + viewport.clientHeight - contentHeight;
}
function computeScrollTopForElement({
align,
element,
scrollMargin,
spacer,
viewport,
}: {
align: MessageScrollerScrollAlign;
element: HTMLElement;
scrollMargin: number;
spacer: HTMLElement | null;
viewport: HTMLElement;
}): number {
const offsetTop = getElementOffsetTop(element, viewport);
const height = element.getBoundingClientRect().height;
const padding = getContentPadding(spacer);
if (align === "center") {
const available = Math.max(
0,
viewport.clientHeight - padding.start - padding.end,
);
return offsetTop - padding.start - (available - height) / 2 - scrollMargin;
}
if (align === "end")
return (
offsetTop - viewport.clientHeight + height + padding.end + scrollMargin
);
if (align === "nearest") {
const bottom = offsetTop + height;
const visibleTop = viewport.scrollTop + padding.start;
const visibleBottom =
viewport.scrollTop + viewport.clientHeight - padding.end;
if (offsetTop >= visibleTop && bottom <= visibleBottom)
return viewport.scrollTop;
return offsetTop < visibleTop
? offsetTop - padding.start - scrollMargin
: bottom - viewport.clientHeight + padding.end + scrollMargin;
}
return offsetTop - padding.start - scrollMargin;
}
function computeScrollable({
content,
scrollEdgeThreshold,
spacer,
viewport,
}: {
content: HTMLElement | null;
scrollEdgeThreshold: number;
spacer: HTMLElement | null;
viewport: HTMLElement | null;
}): MessageScrollerScrollable {
if (!viewport || !content) return EMPTY_SCROLLABLE;
const contentHeight = measureContentHeight({ content, spacer, viewport });
return {
start: viewport.scrollTop > scrollEdgeThreshold,
end:
contentHeight - viewport.scrollTop - viewport.clientHeight >
scrollEdgeThreshold,
};
}
function computeVisibility({
content,
scrollMargin,
scrollPreviousItemPeek,
spacer,
viewport,
visibleMessageIds,
}: {
content: HTMLElement | null;
scrollMargin: number;
scrollPreviousItemPeek: number;
spacer: HTMLElement | null;
viewport: HTMLElement | null;
visibleMessageIds: Set<string>;
}): MessageScrollerVisibilityState {
if (!content || !viewport) return EMPTY_VISIBILITY;
const viewportRect = viewport.getBoundingClientRect();
const anchorLine = viewportRect.top + scrollMargin + scrollPreviousItemPeek;
const noIntersectionObserver = typeof IntersectionObserver === "undefined";
const visible: string[] = [];
let currentAnchorId: string | null = null;
for (const child of getMessageChildren(content, spacer)) {
const messageId = child.dataset.messageId;
if (!messageId) continue;
const isAnchor = child.dataset.scrollAnchor === "true";
const rect =
isAnchor || noIntersectionObserver ? child.getBoundingClientRect() : null;
const isVisible =
noIntersectionObserver && rect
? rect.bottom > anchorLine && rect.top < viewportRect.bottom
: visibleMessageIds.has(messageId);
if (isVisible) visible.push(messageId);
if (isAnchor && rect && rect.top <= anchorLine + SCROLL_EPSILON)
currentAnchorId = messageId;
}
return visible.length === 0 && currentAnchorId === null
? EMPTY_VISIBILITY
: { currentAnchorId, visibleMessageIds: visible };
}
function findFirstAnchorFrom(
elements: HTMLElement[],
startIndex: number,
): HTMLElement | null {
for (let i = startIndex; i < elements.length; i++) {
const element = elements[i];
if (element?.dataset.scrollAnchor === "true") return element;
}
return null;
}
function findFirstUnhandledAnchor(
elements: HTMLElement[],
handled: WeakSet<HTMLElement>,
): HTMLElement | null {
for (const element of elements) {
if (element.dataset.scrollAnchor === "true" && !handled.has(element))
return element;
}
return null;
}
function hasMultipleAnchorsFrom(
elements: HTMLElement[],
startIndex: number,
): boolean {
let count = 0;
for (let i = startIndex; i < elements.length; i++) {
if (elements[i]?.dataset.scrollAnchor === "true") {
count += 1;
if (count > 1) return true;
}
}
return false;
}
function findLastAnchor(elements: HTMLElement[]): HTMLElement | null {
for (let i = elements.length - 1; i >= 0; i--) {
const element = elements[i];
if (element?.dataset.scrollAnchor === "true") return element;
}
return null;
}
function findFirstVisibleMessage({
content,
spacer,
viewport,
}: {
content: HTMLElement;
spacer: HTMLElement | null;
viewport: HTMLElement;
}): HTMLElement | null {
const viewportRect = viewport.getBoundingClientRect();
for (const child of getMessageChildren(content, spacer)) {
if (!child.dataset.messageId) continue;
const rect = child.getBoundingClientRect();
if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom)
return child;
}
return null;
}
// -----------------------------------------------------------------------------
// Engine
// -----------------------------------------------------------------------------
export interface MessageScrollerContext {
autoscrolling: Readonly<ShallowRef<boolean>>;
scrollable: Readonly<ShallowRef<MessageScrollerScrollable>>;
scrollableAttr: ComputedRef<string | undefined>;
visibility: Readonly<ShallowRef<MessageScrollerVisibilityState>>;
acquireVisibility: () => void;
releaseVisibility: () => void;
handleContentChange: () => void;
handleResize: () => void;
scrollToEnd: (options?: { behavior?: ScrollBehavior }) => boolean;
scrollToMessage: (
messageId: string,
options?: MessageScrollerScrollOptions,
) => boolean;
scrollToStart: (options?: { behavior?: ScrollBehavior }) => boolean;
setContentElement: (element: HTMLElement | null) => void;
setSpacerElement: (element: HTMLElement | null) => void;
setViewportElement: (element: HTMLElement | null) => void;
setPreserveScrollOnPrepend: (value: boolean) => void;
syncAfterScroll: () => void;
userScrollIntent: () => void;
}
export type RegisterMessage = (
messageId: string,
element: HTMLElement | null,
previousElement: HTMLElement | null,
) => void;
const CONTEXT_KEY: InjectionKey<MessageScrollerContext> = Symbol(
"MessageScrollerContext",
);
const REGISTER_KEY: InjectionKey<RegisterMessage> = Symbol(
"MessageScrollerRegister",
);
function createEngine(props: MessageScrollerProviderProps) {
const autoScroll = () => props.autoScroll ?? false;
const defaultScrollPosition = () => props.defaultScrollPosition ?? "end";
const scrollEdgeThreshold = () =>
props.scrollEdgeThreshold ?? DEFAULT_SCROLL_EDGE_THRESHOLD;
const scrollPreviousItemPeek = () =>
props.scrollPreviousItemPeek ?? DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK;
const scrollMargin = () => props.scrollMargin ?? DEFAULT_SCROLL_MARGIN;
let viewport: HTMLElement | null = null;
let content: HTMLElement | null = null;
let spacer: HTMLElement | null = null;
let spacerGap = 0;
let spacerHeight = 0;
let mode: Mode = autoScroll() ? "following-bottom" : "free-scrolling";
let streamingTurn: HTMLElement | null = null;
let firstItem: HTMLElement | null = null;
let itemCount = 0;
let lastScrollTop = 0;
let defaultScrollPositionApplied = false;
let preserveScrollOnPrepend = true;
let prependRestore: PrependRestore | null = null;
let pendingScrollToMessage: PendingScrollToMessage | null = null;
let stateFrame: number | null = null;
let visibilityFrame: number | null = null;
let pendingScrollFrame: number | null = null;
let autoscrollingTimeout: number | null = null;
let visibilityObserver: IntersectionObserver | null = null;
let visibilityConsumers = 0;
const messageElements = new Map<string, HTMLElement>();
const visibleMessageIds = new Set<string>();
const handledScrollAnchors = new WeakSet<HTMLElement>();
const autoscrolling = shallowRef(false);
const scrollable = shallowRef<MessageScrollerScrollable>(EMPTY_SCROLLABLE);
const visibility =
shallowRef<MessageScrollerVisibilityState>(EMPTY_VISIBILITY);
const scrollableAttr = computed(() => {
const attr = [
scrollable.value.start && "start",
scrollable.value.end && "end",
]
.filter(Boolean)
.join(" ");
return attr || undefined;
});
// --- scroll-state commit / visibility scheduling ---------------------------
function updateModeFromScroll(next: MessageScrollerScrollable) {
const scrollTop = viewport?.scrollTop ?? 0;
const scrolledUp = scrollTop < lastScrollTop - SCROLL_EPSILON;
lastScrollTop = scrollTop;
if (
autoScroll() &&
!next.end &&
mode !== "settling-jump" &&
mode !== "anchored-to-message"
) {
mode = "following-bottom";
} else if (
mode === "following-bottom" &&
next.end &&
scrolledUp &&
!autoscrolling.value
) {
mode = "free-scrolling";
}
}
function commitScrollState() {
const measured = computeScrollable({
content,
scrollEdgeThreshold: scrollEdgeThreshold(),
spacer,
viewport,
});
updateModeFromScroll(measured);
const next =
mode === "following-bottom" ? { ...measured, end: false } : measured;
if (!scrollableEqual(scrollable.value, next)) scrollable.value = next;
}
function scheduleStateCommit() {
if (stateFrame === null) {
stateFrame = window.requestAnimationFrame(() => {
stateFrame = null;
commitScrollState();
});
}
}
function scheduleVisibilitySync() {
if (visibilityConsumers === 0) return;
if (visibilityFrame === null) {
visibilityFrame = window.requestAnimationFrame(() => {
visibilityFrame = null;
if (visibilityConsumers > 0) {
const next = computeVisibility({
content,
scrollMargin: scrollMargin(),
scrollPreviousItemPeek: scrollPreviousItemPeek(),
spacer,
viewport,
visibleMessageIds,
});
if (!visibilityEqual(visibility.value, next)) visibility.value = next;
}
});
}
}
// --- imperative scroll primitives ------------------------------------------
function setAutoscrolling(active: boolean) {
if (autoscrollingTimeout !== null) {
window.clearTimeout(autoscrollingTimeout);
autoscrollingTimeout = null;
}
if (autoscrolling.value !== active) {
autoscrolling.value = active;
commitScrollState();
}
if (active) {
autoscrollingTimeout = window.setTimeout(() => {
autoscrollingTimeout = null;
autoscrolling.value = false;
commitScrollState();
}, AUTOSCROLLING_TIMEOUT);
}
}
function setSpacerHeight(height: number) {
if (!spacer) return;
const next = Math.max(0, Math.ceil(height));
if (spacerHeight !== next) {
spacerHeight = next;
spacer.hidden = next === 0;
spacer.style.height = `${next}px`;
spacer.style.marginTop = next > 0 ? `${-spacerGap}px` : "";
}
}
function scrollTo(
top: number,
{
behavior = "auto",
autoscrolling: isAutoscrolling = false,
}: { behavior?: ScrollBehavior; autoscrolling?: boolean } = {},
) {
if (!viewport) return;
const target = Math.max(0, top);
if (Math.abs(viewport.scrollTop - target) <= SCROLL_EPSILON) {
viewport.scrollTop = target;
commitScrollState();
return;
}
if (isAutoscrolling) setAutoscrolling(true);
viewport.scrollTo({ top: target, behavior });
scheduleStateCommit();
}
function scrollToStart({
behavior = "auto",
}: {
behavior?: ScrollBehavior;
} = {}): boolean {
if (!viewport) return false;
setSpacerHeight(0);
streamingTurn = null;
mode = "free-scrolling";
scrollTo(0, { behavior });
scheduleVisibilitySync();
return true;
}
function scrollToEnd({
behavior = "auto",
}: {
behavior?: ScrollBehavior;
} = {}): boolean {
if (!viewport) return false;
setSpacerHeight(0);
streamingTurn = null;
mode = autoScroll() ? "following-bottom" : "free-scrolling";
scrollTo(maxScrollTop(viewport), { autoscrolling: true, behavior });
scheduleVisibilitySync();
return true;
}
function scrollToElement(
element: HTMLElement,
{
align = "start",
behavior = "auto",
scrollMargin: margin = scrollMargin(),
}: MessageScrollerScrollOptions = {},
{ keepPreviousPeek = false }: { keepPreviousPeek?: boolean } = {},
): boolean {
if (!content || !viewport || !content.contains(element)) return false;
const targetScrollTop = computeScrollTopForElement({
align,
element,
scrollMargin: keepPreviousPeek
? margin + scrollPreviousItemPeek()
: margin,
spacer,
viewport,
});
setSpacerHeight(
computeSpacerHeight({
content,
scrollTop: targetScrollTop,
spacer,
viewport,
}),
);
prependRestore = {
element,
viewportTop: getRelativeTop(element, viewport),
};
mode = keepPreviousPeek ? "anchored-to-message" : "settling-jump";
streamingTurn = keepPreviousPeek ? element : null;
scrollTo(targetScrollTop, { behavior });
scheduleVisibilitySync();
return true;
}
function reanchorToAnchoredMessage(): boolean {
if (!streamingTurn?.isConnected || mode !== "anchored-to-message")
return false;
return scrollToElement(
streamingTurn,
{ align: "start" },
{ keepPreviousPeek: true },
);
}
function scrollToMessage(
messageId: string,
options?: MessageScrollerScrollOptions,
): boolean {
const element = messageElements.get(messageId);
if (element) {
defaultScrollPositionApplied = true;
if (scrollToElement(element, options)) {
pendingScrollToMessage = null;
return true;
}
pendingScrollToMessage = { messageId, options };
return true;
}
if (itemCount === 0) {
pendingScrollToMessage = { messageId, options };
defaultScrollPositionApplied = true;
return true;
}
return false;
}
function flushPendingScrollToMessage(): boolean {
if (!pendingScrollToMessage) return false;
const element = messageElements.get(pendingScrollToMessage.messageId);
if (!element || !scrollToElement(element, pendingScrollToMessage.options))
return false;
pendingScrollToMessage = null;
defaultScrollPositionApplied = true;
return true;
}
// --- prepend preservation --------------------------------------------------
function applyPrependRestore(): boolean {
if (!prependRestore || !viewport || !prependRestore.element.isConnected)
return false;
const delta =
getRelativeTop(prependRestore.element, viewport) -
prependRestore.viewportTop;
if (Math.abs(delta) <= SCROLL_EPSILON) return false;
viewport.scrollTop += delta;
prependRestore.viewportTop = getRelativeTop(
prependRestore.element,
viewport,
);
scheduleStateCommit();
scheduleVisibilitySync();
return true;
}
function capturePrependAnchor() {
if (!content || !viewport) {
prependRestore = null;
return;
}
const element = findFirstVisibleMessage({ content, spacer, viewport });
prependRestore = element
? { element, viewportTop: getRelativeTop(element, viewport) }
: null;
}
function schedulePrependFlush() {
if (pendingScrollFrame === null) {
pendingScrollFrame = window.requestAnimationFrame(() => {
pendingScrollFrame = null;
if (flushPendingScrollToMessage()) capturePrependAnchor();
});
}
}
// --- default scroll position -----------------------------------------------
function applyDefaultScrollPosition(): boolean {
if (defaultScrollPositionApplied || itemCount === 0) return false;
const position = defaultScrollPosition();
let applied = false;
if (position === "last-anchor") {
const lastAnchor =
content && viewport
? findLastAnchor(getMessageChildren(content, spacer))
: null;
if (!content || !viewport || !lastAnchor) {
applied = scrollToEnd({ behavior: "auto" });
} else {
const anchorOffset = getElementOffsetTop(lastAnchor, viewport);
const contentHeight = measureContentHeight({
content,
spacer,
viewport,
});
applied =
contentHeight - anchorOffset <= viewport.clientHeight
? scrollToEnd({ behavior: "auto" })
: scrollToElement(
lastAnchor,
{ align: "start" },
{ keepPreviousPeek: true },
);
}
} else {
applied =
position === "end"
? scrollToEnd({ behavior: "auto" })
: scrollToStart({ behavior: "auto" });
}
if (applied) {
defaultScrollPositionApplied = true;
return true;
}
return false;
}
// --- content / resize handling ---------------------------------------------
function applyContentChange(
children: HTMLElement[],
previousCount: number,
previousFirst: HTMLElement | null,
) {
if (flushPendingScrollToMessage()) return;
if (previousCount === 0) {
if (
applyDefaultScrollPosition() ||
(children.length > 0 &&
autoScroll() &&
scrollToEnd({ behavior: "auto" }))
) {
return;
}
commitScrollState();
scheduleVisibilitySync();
return;
}
const previousIndex = previousFirst ? children.indexOf(previousFirst) : -1;
if (preserveScrollOnPrepend && previousIndex > 0) {
applyPrependRestore();
return;
}
if (children.length > previousCount) {
const anchor = findFirstAnchorFrom(children, previousCount);
if (anchor) {
if (
autoScroll() &&
mode === "following-bottom" &&
hasMultipleAnchorsFrom(children, previousCount)
) {
scrollToEnd({ behavior: "auto" });
return;
}
scrollToElement(anchor, { align: "start" }, { keepPreviousPeek: true });
handledScrollAnchors.add(anchor);
return;
}
}
if (children.length === previousCount) {
const anchor = findFirstUnhandledAnchor(children, handledScrollAnchors);
if (anchor) {
scrollToElement(anchor, { align: "start" }, { keepPreviousPeek: true });
handledScrollAnchors.add(anchor);
return;
}
}
if (mode === "following-bottom" && autoScroll()) {
scrollToEnd({ behavior: "auto" });
} else {
commitScrollState();
scheduleVisibilitySync();
}
}
function handleContentChange() {
if (!content) return;
const children = getMessageChildren(content, spacer);
const previousCount = itemCount;
const previousFirst = firstItem;
itemCount = children.length;
firstItem = children[0] ?? null;
applyContentChange(children, previousCount, previousFirst);
capturePrependAnchor();
}
function handleResize() {
if (mode === "following-bottom" && autoScroll()) {
scrollToEnd({ behavior: "auto" });
return;
}
const previousSpacerHeight = spacerHeight;
if (reanchorToAnchoredMessage()) {
// The reply streaming below the anchor consumes the tail spacer as it
// grows. Once the last of it is gone the reply has filled the viewport
// and the reader is genuinely at the live edge, so autoScroll hands off
// from the anchor hold to following the bottom. Requiring the >0 → 0
// transition keeps a turn taller than the viewport (placed with no
// spacer) held instead of yanked to the end.
if (autoScroll() && previousSpacerHeight > 0 && spacerHeight === 0)
scrollToEnd({ behavior: "auto" });
return;
}
scheduleStateCommit();
scheduleVisibilitySync();
}
// --- visibility observation ------------------------------------------------
function observeVisibility() {
if (!viewport || visibilityConsumers === 0) return;
if (typeof IntersectionObserver === "undefined") {
scheduleVisibilitySync();
return;
}
if (!visibilityObserver) {
visibilityObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const messageId = (entry.target as HTMLElement).dataset.messageId;
if (!messageId) continue;
if (entry.isIntersecting) visibleMessageIds.add(messageId);
else visibleMessageIds.delete(messageId);
}
scheduleVisibilitySync();
},
{
root: viewport,
rootMargin: `${-(scrollMargin() + scrollPreviousItemPeek())}px 0px 0px 0px`,
threshold: [0, 0.01, 0.5, 1],
},
);
}
messageElements.forEach((element) => {
visibilityObserver?.observe(element);
});
scheduleVisibilitySync();
}
function unobserveVisibility() {
if (visibilityFrame !== null) {
window.cancelAnimationFrame(visibilityFrame);
visibilityFrame = null;
}
visibilityObserver?.disconnect();
visibilityObserver = null;
visibleMessageIds.clear();
if (!visibilityEqual(visibility.value, EMPTY_VISIBILITY))
visibility.value = EMPTY_VISIBILITY;
}
function acquireVisibility() {
visibilityConsumers += 1;
if (visibilityConsumers === 1) observeVisibility();
}
function releaseVisibility() {
visibilityConsumers -= 1;
if (visibilityConsumers === 0) unobserveVisibility();
}
const registerMessage: RegisterMessage = (
messageId,
element,
previousElement,
) => {
if (element) {
messageElements.set(messageId, element);
visibilityObserver?.observe(element);
scheduleVisibilitySync();
if (pendingScrollToMessage?.messageId === messageId)
schedulePrependFlush();
return;
}
if (previousElement && messageElements.get(messageId) === previousElement) {
messageElements.delete(messageId);
visibleMessageIds.delete(messageId);
visibilityObserver?.unobserve(previousElement);
scheduleVisibilitySync();
}
};
// --- user intent + element setters -----------------------------------------
function userScrollIntent() {
if (
mode === "following-bottom" ||
mode === "anchored-to-message" ||
mode === "settling-jump"
) {
streamingTurn = null;
mode = "free-scrolling";
}
}
function setViewportElement(element: HTMLElement | null) {
viewport = element;
// A visibility consumer may have subscribed before the viewport mounted,
// in which case observeVisibility() bailed out. Retry now it exists.
if (element) observeVisibility();
}
function setContentElement(element: HTMLElement | null) {
content = element;
}
function setSpacerElement(element: HTMLElement | null) {
spacer = element;
spacerGap = getRowGap(element?.parentElement ?? null);
}
function setPreserveScrollOnPrepend(value: boolean) {
preserveScrollOnPrepend = value;
}
function syncAfterScroll() {
commitScrollState();
scheduleVisibilitySync();
capturePrependAnchor();
}
function onAutoScrollChange() {
if (autoScroll() && mode === "following-bottom" && itemCount > 0) {
scrollToEnd({ behavior: "auto" });
return;
}
commitScrollState();
}
function destroy() {
if (stateFrame !== null) {
window.cancelAnimationFrame(stateFrame);
stateFrame = null;
}
if (visibilityFrame !== null) {
window.cancelAnimationFrame(visibilityFrame);
visibilityFrame = null;
}
if (autoscrollingTimeout !== null) {
window.clearTimeout(autoscrollingTimeout);
autoscrollingTimeout = null;
}
if (pendingScrollFrame !== null) {
window.cancelAnimationFrame(pendingScrollFrame);
pendingScrollFrame = null;
}
visibilityObserver?.disconnect();
visibilityObserver = null;
}
const context: MessageScrollerContext = {
autoscrolling,
scrollable,
scrollableAttr,
visibility,
acquireVisibility,
releaseVisibility,
handleContentChange,
handleResize,
scrollToEnd,
scrollToMessage,
scrollToStart,
setContentElement,
setSpacerElement,
setViewportElement,
setPreserveScrollOnPrepend,
syncAfterScroll,
userScrollIntent,
};
return {
context,
registerMessage,
applyDefaultScrollPosition,
onAutoScrollChange,
destroy,
};
}
// -----------------------------------------------------------------------------
// Provider wiring (provide/inject)
// -----------------------------------------------------------------------------
export function provideMessageScroller(props: MessageScrollerProviderProps) {
const engine = createEngine(props);
provide(CONTEXT_KEY, engine.context);
provide(REGISTER_KEY, engine.registerMessage);
watch(
() => props.autoScroll ?? false,
() => engine.onAutoScrollChange(),
);
onMounted(() => {
engine.applyDefaultScrollPosition();
// The viewport element attaches in MessageScrollerViewport's onMounted,
// after MessageScrollerContent ran its initial handleContentChange without
// it. Re-sync now that every element is wired up.
engine.context.syncAfterScroll();
});
onScopeDispose(() => engine.destroy());
return engine;
}
export function useMessageScrollerContext(): MessageScrollerContext {
const context = inject(CONTEXT_KEY, null);
if (!context)
throw new Error(
"useMessageScroller must be used within a MessageScroller.",
);
return context;
}
export function useMessageScrollerRegister(): RegisterMessage {
const register = inject(REGISTER_KEY, null);
if (!register)
throw new Error(
"MessageScrollerItem must be used within a MessageScroller.",
);
return register;
}
// -----------------------------------------------------------------------------
// Public composables
// -----------------------------------------------------------------------------
export function useMessageScroller() {
const { scrollToEnd, scrollToMessage, scrollToStart } =
useMessageScrollerContext();
return { scrollToEnd, scrollToMessage, scrollToStart };
}
export function useMessageScrollerScrollable(): Ref<MessageScrollerScrollable> {
const { scrollable } = useMessageScrollerContext();
return computed(() => scrollable.value);
}
export function useMessageScrollerVisibility(): Ref<MessageScrollerVisibilityState> {
const { acquireVisibility, releaseVisibility, visibility } =
useMessageScrollerContext();
acquireVisibility();
if (getCurrentScope()) onScopeDispose(releaseVisibility);
return computed(() => visibility.value);
}
export { SCROLL_KEYS };
registry/ui/message-scroller/index.ts
export { default as MessageScroller } from "./MessageScroller.vue";
export { default as MessageScrollerButton } from "./MessageScrollerButton.vue";
export { default as MessageScrollerContent } from "./MessageScrollerContent.vue";
export { default as MessageScrollerItem } from "./MessageScrollerItem.vue";
export { default as MessageScrollerProvider } from "./MessageScrollerProvider.vue";
export { default as MessageScrollerViewport } from "./MessageScrollerViewport.vue";
export type {
MessageScrollerButtonDirection,
MessageScrollerDefaultScrollPosition,
MessageScrollerProviderProps,
MessageScrollerScrollAlign,
MessageScrollerScrollable,
MessageScrollerScrollOptions,
MessageScrollerVisibilityState,
} from "./useMessageScroller";
export {
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "./useMessageScroller";
src/lib/registry/ui/message-scroller/message-scroller-button.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import ArrowDownIcon from "phosphor-svelte/lib/ArrowDown";
import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js";
import {
type MessageScrollerButtonDirection,
useMessageScrollerContext,
} from "./use-message-scroller.svelte.js";
let {
ref = $bindable(null),
class: className,
direction = "end",
behavior = "smooth",
variant = "outline",
size = "sm",
children,
onclick,
...restProps
}: Omit<ButtonProps, "href" | "onclick"> & {
onclick?: (event: MouseEvent) => void;
direction?: MessageScrollerButtonDirection;
behavior?: ScrollBehavior;
} = $props();
const scroller = useMessageScrollerContext();
const active = $derived(direction === "start" ? scroller.scrollable.start : scroller.scrollable.end);
function handleClick(event: MouseEvent) {
if (!active) return;
(event.currentTarget as HTMLElement | null)?.blur();
onclick?.(event);
if (event.defaultPrevented) return;
if (direction === "start") scroller.scrollToStart({ behavior });
else scroller.scrollToEnd({ behavior });
}
</script>
<Button
bind:ref
data-slot="message-scroller-button"
data-direction={direction}
data-active={active ? "true" : "false"}
{variant}
{size}
inert={!active}
tabindex={active ? undefined : -1}
class={cn(
"absolute left-1/2 -translate-x-1/2 transition-[translate,scale,opacity] duration-200 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 data-[direction=start]:[&_svg]:rotate-180",
className
)}
onclick={handleClick}
{...restProps}
>
{#if children}
{@render children()}
{:else}
<ArrowDownIcon
/>
<!-- ✦ Edmi: labelled pill, "Jump to latest" -->
{direction === "end" ? "Jump to latest" : "Jump to start"}
{/if}
</Button>
src/lib/registry/ui/message-scroller/message-scroller-content.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { onMount } from "svelte";
import { useMessageScrollerContext } from "./use-message-scroller.svelte.js";
let {
ref = $bindable(null),
class: className,
spacerClass,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { spacerClass?: string } = $props();
const scroller = useMessageScrollerContext();
let spacer: HTMLDivElement | null = $state(null);
onMount(() => {
const content = ref;
if (!content) return;
scroller.setContentElement(content);
scroller.setSpacerElement(spacer);
scroller.handleContentChange();
let frame = 0;
let mutationObserver: MutationObserver | null = null;
let resizeObserver: ResizeObserver | null = null;
if (typeof MutationObserver !== "undefined") {
mutationObserver = new MutationObserver(() => scroller.handleContentChange());
mutationObserver.observe(content, { childList: true });
}
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(scroller.handleResize);
});
resizeObserver.observe(content);
}
return () => {
window.cancelAnimationFrame(frame);
mutationObserver?.disconnect();
resizeObserver?.disconnect();
scroller.setContentElement(null);
scroller.setSpacerElement(null);
};
});
</script>
<div
bind:this={ref}
data-slot="message-scroller-content"
role="log"
aria-relevant="additions"
class={cn("flex h-max min-h-full flex-col gap-6", className)}
{...restProps}
>
{@render children?.()}
<div
bind:this={spacer}
aria-hidden="true"
data-message-scroller-spacer=""
hidden
class={spacerClass}
></div>
</div>
src/lib/registry/ui/message-scroller/message-scroller-item.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { useMessageScrollerRegister } from "./use-message-scroller.svelte.js";
let {
ref = $bindable(null),
class: className,
messageId = undefined,
scrollAnchor = false,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
messageId?: string;
scrollAnchor?: boolean;
} = $props();
const register = useMessageScrollerRegister();
$effect(() => {
const element = ref;
const id = messageId;
if (!id || !element) return;
register(id, element, null);
return () => register(id, null, element);
});
</script>
<div
bind:this={ref}
data-slot="message-scroller-item"
data-message-id={messageId}
data-scroll-anchor={scrollAnchor ? "true" : "false"}
class={cn("min-w-0 shrink-0 [contain-intrinsic-size:auto_10rem] [content-visibility:auto]", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/message-scroller/message-scroller-provider.svelte
<script lang="ts">
import type { Snippet } from "svelte";
import {
type MessageScrollerProviderProps,
provideMessageScroller,
} from "./use-message-scroller.svelte.js";
let { children, ...props }: MessageScrollerProviderProps & { children?: Snippet } = $props();
// `props` is a live proxy; the engine reads it lazily, so it stays reactive.
// svelte-ignore state_referenced_locally
provideMessageScroller(props);
</script>
{@render children?.()}
src/lib/registry/ui/message-scroller/message-scroller-viewport.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { onMount } from "svelte";
import { SCROLL_KEYS, useMessageScrollerContext } from "./use-message-scroller.svelte.js";
let {
ref = $bindable(null),
class: className,
preserveScrollOnPrepend = true,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
preserveScrollOnPrepend?: boolean;
} = $props();
const scroller = useMessageScrollerContext();
$effect(() => {
scroller.setPreserveScrollOnPrepend(preserveScrollOnPrepend);
});
onMount(() => {
const viewport = ref;
scroller.setViewportElement(viewport);
let frame = 0;
let observer: ResizeObserver | null = null;
if (viewport && typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(() => {
window.cancelAnimationFrame(frame);
frame = window.requestAnimationFrame(scroller.handleResize);
});
observer.observe(viewport);
}
return () => {
window.cancelAnimationFrame(frame);
observer?.disconnect();
scroller.setViewportElement(null);
};
});
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div
bind:this={ref}
data-slot="message-scroller-viewport"
role="region"
aria-label="Messages"
tabindex="0"
data-scrollable={scroller.scrollableAttr}
data-autoscrolling={scroller.autoscrolling ? "" : undefined}
class={cn(
"size-full min-h-0 min-w-0 scroll-fade-b scrollbar-thin scrollbar-gutter-stable overflow-y-auto overscroll-contain contain-content data-[autoscrolling]:scrollbar-thumb-transparent data-[autoscrolling]:scrollbar-track-transparent",
className
)}
onscroll={() => scroller.syncAfterScroll()}
onwheel={() => scroller.userScrollIntent()}
ontouchmove={() => scroller.userScrollIntent()}
onkeydown={(event) => {
if (SCROLL_KEYS.has(event.key)) scroller.userScrollIntent();
}}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/message-scroller/message-scroller.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import { useMessageScrollerContext } from "./use-message-scroller.svelte.js";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
const scroller = useMessageScrollerContext();
</script>
<div
bind:this={ref}
data-slot="message-scroller"
data-scrollable={scroller.scrollableAttr}
data-autoscrolling={scroller.autoscrolling ? "" : undefined}
class={cn("group/message-scroller relative flex size-full min-h-0 flex-col overflow-hidden", className)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/message-scroller/use-message-scroller.svelte.ts
import { getContext, onDestroy, onMount, setContext } from "svelte";
// -----------------------------------------------------------------------------
// Public types
// -----------------------------------------------------------------------------
export type MessageScrollerDefaultScrollPosition =
| "start"
| "end"
| "last-anchor";
export type MessageScrollerButtonDirection = "start" | "end";
export type MessageScrollerScrollAlign = "start" | "center" | "end" | "nearest";
export interface MessageScrollerScrollOptions {
align?: MessageScrollerScrollAlign;
behavior?: ScrollBehavior;
scrollMargin?: number;
}
export interface MessageScrollerScrollable {
start: boolean;
end: boolean;
}
export interface MessageScrollerVisibilityState {
currentAnchorId: string | null;
visibleMessageIds: string[];
}
export interface MessageScrollerProviderProps {
autoScroll?: boolean;
defaultScrollPosition?: MessageScrollerDefaultScrollPosition;
scrollEdgeThreshold?: number;
scrollPreviousItemPeek?: number;
scrollMargin?: number;
}
// -----------------------------------------------------------------------------
// Constants
// -----------------------------------------------------------------------------
const DEFAULT_SCROLL_EDGE_THRESHOLD = 8;
const DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK = 64;
const DEFAULT_SCROLL_MARGIN = 0;
const SCROLL_EPSILON = 0.5;
const AUTOSCROLLING_TIMEOUT = 180;
const SCROLL_KEYS = new Set([
"ArrowDown",
"ArrowUp",
"End",
"Home",
"PageDown",
"PageUp",
" ",
]);
const EMPTY_SCROLLABLE: MessageScrollerScrollable = {
start: false,
end: false,
};
const EMPTY_VISIBLE_IDS: string[] = [];
const EMPTY_VISIBILITY: MessageScrollerVisibilityState = {
currentAnchorId: null,
visibleMessageIds: EMPTY_VISIBLE_IDS,
};
type Mode =
| "following-bottom"
| "free-scrolling"
| "anchored-to-message"
| "settling-jump";
interface PrependRestore {
element: HTMLElement;
viewportTop: number;
}
interface PendingScrollToMessage {
messageId: string;
options?: MessageScrollerScrollOptions;
}
function scrollableEqual(
a: MessageScrollerScrollable,
b: MessageScrollerScrollable,
) {
return a.start === b.start && a.end === b.end;
}
function visibilityEqual(
a: MessageScrollerVisibilityState,
b: MessageScrollerVisibilityState,
) {
if (
a.currentAnchorId !== b.currentAnchorId ||
a.visibleMessageIds.length !== b.visibleMessageIds.length
) {
return false;
}
return a.visibleMessageIds.every(
(id, index) => id === b.visibleMessageIds[index],
);
}
// -----------------------------------------------------------------------------
// DOM measurement helpers
// -----------------------------------------------------------------------------
function parseNumber(value: string | null | undefined): number {
if (!value) return 0;
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function getPadding(element: HTMLElement): { start: number; end: number } {
const style = window.getComputedStyle(element);
return {
end: parseNumber(style.paddingBlockEnd || style.paddingBottom),
start: parseNumber(style.paddingBlockStart || style.paddingTop),
};
}
function getContentPadding(spacer: HTMLElement | null): {
start: number;
end: number;
} {
const parent = spacer?.parentElement;
return parent ? getPadding(parent) : { end: 0, start: 0 };
}
function getRowGap(element: HTMLElement | null): number {
if (!element) return 0;
const style = window.getComputedStyle(element);
const gap = style.rowGap === "normal" ? style.gap : style.rowGap;
return parseNumber(gap);
}
function getMessageChildren(
content: HTMLElement,
spacer: HTMLElement | null,
): HTMLElement[] {
return Array.from(content.children).filter(
(child): child is HTMLElement =>
child instanceof HTMLElement && child !== spacer,
);
}
function getElementOffsetTop(
element: HTMLElement,
viewport: HTMLElement,
): number {
const elementRect = element.getBoundingClientRect();
const viewportRect = viewport.getBoundingClientRect();
return elementRect.top - viewportRect.top + viewport.scrollTop;
}
function getRelativeTop(element: HTMLElement, viewport: HTMLElement): number {
return (
element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
);
}
function measureContentHeight({
content,
spacer,
viewport,
}: {
content: HTMLElement;
spacer: HTMLElement | null;
viewport: HTMLElement;
}): number {
const children = getMessageChildren(content, spacer);
const padding = getPadding(content);
const viewportRect = viewport.getBoundingClientRect();
const scrollTop = viewport.scrollTop;
let height = padding.start + padding.end;
for (const child of children) {
const rect = child.getBoundingClientRect();
height = Math.max(
height,
rect.bottom - viewportRect.top + scrollTop + padding.end,
);
}
return height;
}
function maxScrollTop(element: HTMLElement): number {
return Math.max(0, element.scrollHeight - element.clientHeight);
}
function computeSpacerHeight({
content,
scrollTop,
spacer,
viewport,
}: {
content: HTMLElement;
scrollTop: number;
spacer: HTMLElement | null;
viewport: HTMLElement;
}): number {
const contentHeight = measureContentHeight({ content, spacer, viewport });
return scrollTop + viewport.clientHeight - contentHeight;
}
function computeScrollTopForElement({
align,
element,
scrollMargin,
spacer,
viewport,
}: {
align: MessageScrollerScrollAlign;
element: HTMLElement;
scrollMargin: number;
spacer: HTMLElement | null;
viewport: HTMLElement;
}): number {
const offsetTop = getElementOffsetTop(element, viewport);
const height = element.getBoundingClientRect().height;
const padding = getContentPadding(spacer);
if (align === "center") {
const available = Math.max(
0,
viewport.clientHeight - padding.start - padding.end,
);
return offsetTop - padding.start - (available - height) / 2 - scrollMargin;
}
if (align === "end")
return (
offsetTop - viewport.clientHeight + height + padding.end + scrollMargin
);
if (align === "nearest") {
const bottom = offsetTop + height;
const visibleTop = viewport.scrollTop + padding.start;
const visibleBottom =
viewport.scrollTop + viewport.clientHeight - padding.end;
if (offsetTop >= visibleTop && bottom <= visibleBottom)
return viewport.scrollTop;
return offsetTop < visibleTop
? offsetTop - padding.start - scrollMargin
: bottom - viewport.clientHeight + padding.end + scrollMargin;
}
return offsetTop - padding.start - scrollMargin;
}
function computeScrollable({
content,
scrollEdgeThreshold,
spacer,
viewport,
}: {
content: HTMLElement | null;
scrollEdgeThreshold: number;
spacer: HTMLElement | null;
viewport: HTMLElement | null;
}): MessageScrollerScrollable {
if (!viewport || !content) return EMPTY_SCROLLABLE;
const contentHeight = measureContentHeight({ content, spacer, viewport });
return {
start: viewport.scrollTop > scrollEdgeThreshold,
end:
contentHeight - viewport.scrollTop - viewport.clientHeight >
scrollEdgeThreshold,
};
}
function computeVisibility({
content,
scrollMargin,
scrollPreviousItemPeek,
spacer,
viewport,
visibleMessageIds,
}: {
content: HTMLElement | null;
scrollMargin: number;
scrollPreviousItemPeek: number;
spacer: HTMLElement | null;
viewport: HTMLElement | null;
visibleMessageIds: Set<string>;
}): MessageScrollerVisibilityState {
if (!content || !viewport) return EMPTY_VISIBILITY;
const viewportRect = viewport.getBoundingClientRect();
const anchorLine = viewportRect.top + scrollMargin + scrollPreviousItemPeek;
const noIntersectionObserver = typeof IntersectionObserver === "undefined";
const visible: string[] = [];
let currentAnchorId: string | null = null;
for (const child of getMessageChildren(content, spacer)) {
const messageId = child.dataset.messageId;
if (!messageId) continue;
const isAnchor = child.dataset.scrollAnchor === "true";
const rect =
isAnchor || noIntersectionObserver ? child.getBoundingClientRect() : null;
const isVisible =
noIntersectionObserver && rect
? rect.bottom > anchorLine && rect.top < viewportRect.bottom
: visibleMessageIds.has(messageId);
if (isVisible) visible.push(messageId);
if (isAnchor && rect && rect.top <= anchorLine + SCROLL_EPSILON)
currentAnchorId = messageId;
}
return visible.length === 0 && currentAnchorId === null
? EMPTY_VISIBILITY
: { currentAnchorId, visibleMessageIds: visible };
}
function findFirstAnchorFrom(
elements: HTMLElement[],
startIndex: number,
): HTMLElement | null {
for (let i = startIndex; i < elements.length; i++) {
const element = elements[i];
if (element?.dataset.scrollAnchor === "true") return element;
}
return null;
}
function findFirstUnhandledAnchor(
elements: HTMLElement[],
handled: WeakSet<HTMLElement>,
): HTMLElement | null {
for (const element of elements) {
if (element.dataset.scrollAnchor === "true" && !handled.has(element))
return element;
}
return null;
}
function hasMultipleAnchorsFrom(
elements: HTMLElement[],
startIndex: number,
): boolean {
let count = 0;
for (let i = startIndex; i < elements.length; i++) {
if (elements[i]?.dataset.scrollAnchor === "true") {
count += 1;
if (count > 1) return true;
}
}
return false;
}
function findLastAnchor(elements: HTMLElement[]): HTMLElement | null {
for (let i = elements.length - 1; i >= 0; i--) {
const element = elements[i];
if (element?.dataset.scrollAnchor === "true") return element;
}
return null;
}
function findFirstVisibleMessage({
content,
spacer,
viewport,
}: {
content: HTMLElement;
spacer: HTMLElement | null;
viewport: HTMLElement;
}): HTMLElement | null {
const viewportRect = viewport.getBoundingClientRect();
for (const child of getMessageChildren(content, spacer)) {
if (!child.dataset.messageId) continue;
const rect = child.getBoundingClientRect();
if (rect.bottom > viewportRect.top && rect.top < viewportRect.bottom)
return child;
}
return null;
}
// -----------------------------------------------------------------------------
// Engine
// -----------------------------------------------------------------------------
export interface MessageScrollerContext {
readonly autoscrolling: boolean;
readonly scrollable: MessageScrollerScrollable;
readonly scrollableAttr: string | undefined;
readonly visibility: MessageScrollerVisibilityState;
acquireVisibility: () => void;
releaseVisibility: () => void;
handleContentChange: () => void;
handleResize: () => void;
scrollToEnd: (options?: { behavior?: ScrollBehavior }) => boolean;
scrollToMessage: (
messageId: string,
options?: MessageScrollerScrollOptions,
) => boolean;
scrollToStart: (options?: { behavior?: ScrollBehavior }) => boolean;
setContentElement: (element: HTMLElement | null) => void;
setSpacerElement: (element: HTMLElement | null) => void;
setViewportElement: (element: HTMLElement | null) => void;
setPreserveScrollOnPrepend: (value: boolean) => void;
syncAfterScroll: () => void;
userScrollIntent: () => void;
}
export type RegisterMessage = (
messageId: string,
element: HTMLElement | null,
previousElement: HTMLElement | null,
) => void;
const CONTEXT_KEY = Symbol("MessageScrollerContext");
const REGISTER_KEY = Symbol("MessageScrollerRegister");
function createEngine(props: MessageScrollerProviderProps) {
const autoScroll = () => props.autoScroll ?? false;
const defaultScrollPosition = () => props.defaultScrollPosition ?? "end";
const scrollEdgeThreshold = () =>
props.scrollEdgeThreshold ?? DEFAULT_SCROLL_EDGE_THRESHOLD;
const scrollPreviousItemPeek = () =>
props.scrollPreviousItemPeek ?? DEFAULT_SCROLL_PREVIOUS_ITEM_PEEK;
const scrollMargin = () => props.scrollMargin ?? DEFAULT_SCROLL_MARGIN;
let viewport: HTMLElement | null = null;
let content: HTMLElement | null = null;
let spacer: HTMLElement | null = null;
let spacerGap = 0;
let spacerHeight = 0;
let mode: Mode = autoScroll() ? "following-bottom" : "free-scrolling";
let streamingTurn: HTMLElement | null = null;
let firstItem: HTMLElement | null = null;
let itemCount = 0;
let lastScrollTop = 0;
let defaultScrollPositionApplied = false;
let preserveScrollOnPrepend = true;
let prependRestore: PrependRestore | null = null;
let pendingScrollToMessage: PendingScrollToMessage | null = null;
let stateFrame: number | null = null;
let visibilityFrame: number | null = null;
let pendingScrollFrame: number | null = null;
let autoscrollingTimeout: number | null = null;
let visibilityObserver: IntersectionObserver | null = null;
let visibilityConsumers = 0;
const messageElements = new Map<string, HTMLElement>();
const visibleMessageIds = new Set<string>();
const handledScrollAnchors = new WeakSet<HTMLElement>();
let autoscrolling = $state(false);
let scrollable = $state.raw<MessageScrollerScrollable>(EMPTY_SCROLLABLE);
let visibility = $state.raw<MessageScrollerVisibilityState>(EMPTY_VISIBILITY);
const scrollableAttr = () => {
const attr = [scrollable.start && "start", scrollable.end && "end"]
.filter(Boolean)
.join(" ");
return attr || undefined;
};
// --- scroll-state commit / visibility scheduling ---------------------------
function updateModeFromScroll(next: MessageScrollerScrollable) {
const scrollTop = viewport?.scrollTop ?? 0;
const scrolledUp = scrollTop < lastScrollTop - SCROLL_EPSILON;
lastScrollTop = scrollTop;
if (
autoScroll() &&
!next.end &&
mode !== "settling-jump" &&
mode !== "anchored-to-message"
) {
mode = "following-bottom";
} else if (
mode === "following-bottom" &&
next.end &&
scrolledUp &&
!autoscrolling
) {
mode = "free-scrolling";
}
}
function commitScrollState() {
const measured = computeScrollable({
content,
scrollEdgeThreshold: scrollEdgeThreshold(),
spacer,
viewport,
});
updateModeFromScroll(measured);
const next =
mode === "following-bottom" ? { ...measured, end: false } : measured;
if (!scrollableEqual(scrollable, next)) scrollable = next;
}
function scheduleStateCommit() {
if (stateFrame === null) {
stateFrame = window.requestAnimationFrame(() => {
stateFrame = null;
commitScrollState();
});
}
}
function scheduleVisibilitySync() {
if (visibilityConsumers === 0) return;
if (visibilityFrame === null) {
visibilityFrame = window.requestAnimationFrame(() => {
visibilityFrame = null;
if (visibilityConsumers > 0) {
const next = computeVisibility({
content,
scrollMargin: scrollMargin(),
scrollPreviousItemPeek: scrollPreviousItemPeek(),
spacer,
viewport,
visibleMessageIds,
});
if (!visibilityEqual(visibility, next)) visibility = next;
}
});
}
}
// --- imperative scroll primitives ------------------------------------------
function setAutoscrolling(active: boolean) {
if (autoscrollingTimeout !== null) {
window.clearTimeout(autoscrollingTimeout);
autoscrollingTimeout = null;
}
if (autoscrolling !== active) {
autoscrolling = active;
commitScrollState();
}
if (active) {
autoscrollingTimeout = window.setTimeout(() => {
autoscrollingTimeout = null;
autoscrolling = false;
commitScrollState();
}, AUTOSCROLLING_TIMEOUT);
}
}
function setSpacerHeight(height: number) {
if (!spacer) return;
const next = Math.max(0, Math.ceil(height));
if (spacerHeight !== next) {
spacerHeight = next;
spacer.hidden = next === 0;
spacer.style.height = `${next}px`;
spacer.style.marginTop = next > 0 ? `${-spacerGap}px` : "";
}
}
function scrollTo(
top: number,
{
behavior = "auto",
autoscrolling: isAutoscrolling = false,
}: { behavior?: ScrollBehavior; autoscrolling?: boolean } = {},
) {
if (!viewport) return;
const target = Math.max(0, top);
if (Math.abs(viewport.scrollTop - target) <= SCROLL_EPSILON) {
viewport.scrollTop = target;
commitScrollState();
return;
}
if (isAutoscrolling) setAutoscrolling(true);
viewport.scrollTo({ top: target, behavior });
scheduleStateCommit();
}
function scrollToStart({
behavior = "auto",
}: {
behavior?: ScrollBehavior;
} = {}): boolean {
if (!viewport) return false;
setSpacerHeight(0);
streamingTurn = null;
mode = "free-scrolling";
scrollTo(0, { behavior });
scheduleVisibilitySync();
return true;
}
function scrollToEnd({
behavior = "auto",
}: {
behavior?: ScrollBehavior;
} = {}): boolean {
if (!viewport) return false;
setSpacerHeight(0);
streamingTurn = null;
mode = autoScroll() ? "following-bottom" : "free-scrolling";
scrollTo(maxScrollTop(viewport), { autoscrolling: true, behavior });
scheduleVisibilitySync();
return true;
}
function scrollToElement(
element: HTMLElement,
{
align = "start",
behavior = "auto",
scrollMargin: margin = scrollMargin(),
}: MessageScrollerScrollOptions = {},
{ keepPreviousPeek = false }: { keepPreviousPeek?: boolean } = {},
): boolean {
if (!content || !viewport || !content.contains(element)) return false;
const targetScrollTop = computeScrollTopForElement({
align,
element,
scrollMargin: keepPreviousPeek
? margin + scrollPreviousItemPeek()
: margin,
spacer,
viewport,
});
setSpacerHeight(
computeSpacerHeight({
content,
scrollTop: targetScrollTop,
spacer,
viewport,
}),
);
prependRestore = {
element,
viewportTop: getRelativeTop(element, viewport),
};
mode = keepPreviousPeek ? "anchored-to-message" : "settling-jump";
streamingTurn = keepPreviousPeek ? element : null;
scrollTo(targetScrollTop, { behavior });
scheduleVisibilitySync();
return true;
}
function reanchorToAnchoredMessage(): boolean {
if (!streamingTurn?.isConnected || mode !== "anchored-to-message")
return false;
return scrollToElement(
streamingTurn,
{ align: "start" },
{ keepPreviousPeek: true },
);
}
function scrollToMessage(
messageId: string,
options?: MessageScrollerScrollOptions,
): boolean {
const element = messageElements.get(messageId);
if (element) {
defaultScrollPositionApplied = true;
if (scrollToElement(element, options)) {
pendingScrollToMessage = null;
return true;
}
pendingScrollToMessage = { messageId, options };
return true;
}
if (itemCount === 0) {
pendingScrollToMessage = { messageId, options };
defaultScrollPositionApplied = true;
return true;
}
return false;
}
function flushPendingScrollToMessage(): boolean {
if (!pendingScrollToMessage) return false;
const element = messageElements.get(pendingScrollToMessage.messageId);
if (!element || !scrollToElement(element, pendingScrollToMessage.options))
return false;
pendingScrollToMessage = null;
defaultScrollPositionApplied = true;
return true;
}
// --- prepend preservation --------------------------------------------------
function applyPrependRestore(): boolean {
if (!prependRestore || !viewport || !prependRestore.element.isConnected)
return false;
const delta =
getRelativeTop(prependRestore.element, viewport) -
prependRestore.viewportTop;
if (Math.abs(delta) <= SCROLL_EPSILON) return false;
viewport.scrollTop += delta;
prependRestore.viewportTop = getRelativeTop(
prependRestore.element,
viewport,
);
scheduleStateCommit();
scheduleVisibilitySync();
return true;
}
function capturePrependAnchor() {
if (!content || !viewport) {
prependRestore = null;
return;
}
const element = findFirstVisibleMessage({ content, spacer, viewport });
prependRestore = element
? { element, viewportTop: getRelativeTop(element, viewport) }
: null;
}
function schedulePrependFlush() {
if (pendingScrollFrame === null) {
pendingScrollFrame = window.requestAnimationFrame(() => {
pendingScrollFrame = null;
if (flushPendingScrollToMessage()) capturePrependAnchor();
});
}
}
// --- default scroll position -----------------------------------------------
function applyDefaultScrollPosition(): boolean {
if (defaultScrollPositionApplied || itemCount === 0) return false;
const position = defaultScrollPosition();
let applied = false;
if (position === "last-anchor") {
const lastAnchor =
content && viewport
? findLastAnchor(getMessageChildren(content, spacer))
: null;
if (!content || !viewport || !lastAnchor) {
applied = scrollToEnd({ behavior: "auto" });
} else {
const anchorOffset = getElementOffsetTop(lastAnchor, viewport);
const contentHeight = measureContentHeight({
content,
spacer,
viewport,
});
applied =
contentHeight - anchorOffset <= viewport.clientHeight
? scrollToEnd({ behavior: "auto" })
: scrollToElement(
lastAnchor,
{ align: "start" },
{ keepPreviousPeek: true },
);
}
} else {
applied =
position === "end"
? scrollToEnd({ behavior: "auto" })
: scrollToStart({ behavior: "auto" });
}
if (applied) {
defaultScrollPositionApplied = true;
return true;
}
return false;
}
// --- content / resize handling ---------------------------------------------
function applyContentChange(
children: HTMLElement[],
previousCount: number,
previousFirst: HTMLElement | null,
) {
if (flushPendingScrollToMessage()) return;
if (previousCount === 0) {
if (
applyDefaultScrollPosition() ||
(children.length > 0 &&
autoScroll() &&
scrollToEnd({ behavior: "auto" }))
) {
return;
}
commitScrollState();
scheduleVisibilitySync();
return;
}
const previousIndex = previousFirst ? children.indexOf(previousFirst) : -1;
if (preserveScrollOnPrepend && previousIndex > 0) {
applyPrependRestore();
return;
}
if (children.length > previousCount) {
const anchor = findFirstAnchorFrom(children, previousCount);
if (anchor) {
if (
autoScroll() &&
mode === "following-bottom" &&
hasMultipleAnchorsFrom(children, previousCount)
) {
scrollToEnd({ behavior: "auto" });
return;
}
scrollToElement(anchor, { align: "start" }, { keepPreviousPeek: true });
handledScrollAnchors.add(anchor);
return;
}
}
if (children.length === previousCount) {
const anchor = findFirstUnhandledAnchor(children, handledScrollAnchors);
if (anchor) {
scrollToElement(anchor, { align: "start" }, { keepPreviousPeek: true });
handledScrollAnchors.add(anchor);
return;
}
}
if (mode === "following-bottom" && autoScroll()) {
scrollToEnd({ behavior: "auto" });
} else {
commitScrollState();
scheduleVisibilitySync();
}
}
function handleContentChange() {
if (!content) return;
const children = getMessageChildren(content, spacer);
const previousCount = itemCount;
const previousFirst = firstItem;
itemCount = children.length;
firstItem = children[0] ?? null;
applyContentChange(children, previousCount, previousFirst);
capturePrependAnchor();
}
function handleResize() {
if (mode === "following-bottom" && autoScroll()) {
scrollToEnd({ behavior: "auto" });
return;
}
const previousSpacerHeight = spacerHeight;
if (reanchorToAnchoredMessage()) {
// The reply streaming below the anchor consumes the tail spacer as it
// grows. Once the last of it is gone the reply has filled the viewport
// and the reader is genuinely at the live edge, so autoScroll hands off
// from the anchor hold to following the bottom. Requiring the >0 → 0
// transition keeps a turn taller than the viewport (placed with no
// spacer) held instead of yanked to the end.
if (autoScroll() && previousSpacerHeight > 0 && spacerHeight === 0)
scrollToEnd({ behavior: "auto" });
return;
}
scheduleStateCommit();
scheduleVisibilitySync();
}
// --- visibility observation ------------------------------------------------
function observeVisibility() {
if (!viewport || visibilityConsumers === 0) return;
if (typeof IntersectionObserver === "undefined") {
scheduleVisibilitySync();
return;
}
if (!visibilityObserver) {
visibilityObserver = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const messageId = (entry.target as HTMLElement).dataset.messageId;
if (!messageId) continue;
if (entry.isIntersecting) visibleMessageIds.add(messageId);
else visibleMessageIds.delete(messageId);
}
scheduleVisibilitySync();
},
{
root: viewport,
rootMargin: `${-(scrollMargin() + scrollPreviousItemPeek())}px 0px 0px 0px`,
threshold: [0, 0.01, 0.5, 1],
},
);
}
messageElements.forEach((element) => {
visibilityObserver?.observe(element);
});
scheduleVisibilitySync();
}
function unobserveVisibility() {
if (visibilityFrame !== null) {
window.cancelAnimationFrame(visibilityFrame);
visibilityFrame = null;
}
visibilityObserver?.disconnect();
visibilityObserver = null;
visibleMessageIds.clear();
if (!visibilityEqual(visibility, EMPTY_VISIBILITY))
visibility = EMPTY_VISIBILITY;
}
function acquireVisibility() {
visibilityConsumers += 1;
if (visibilityConsumers === 1) observeVisibility();
}
function releaseVisibility() {
visibilityConsumers -= 1;
if (visibilityConsumers === 0) unobserveVisibility();
}
const registerMessage: RegisterMessage = (
messageId,
element,
previousElement,
) => {
if (element) {
messageElements.set(messageId, element);
visibilityObserver?.observe(element);
scheduleVisibilitySync();
if (pendingScrollToMessage?.messageId === messageId)
schedulePrependFlush();
return;
}
if (previousElement && messageElements.get(messageId) === previousElement) {
messageElements.delete(messageId);
visibleMessageIds.delete(messageId);
visibilityObserver?.unobserve(previousElement);
scheduleVisibilitySync();
}
};
// --- user intent + element setters -----------------------------------------
function userScrollIntent() {
if (
mode === "following-bottom" ||
mode === "anchored-to-message" ||
mode === "settling-jump"
) {
streamingTurn = null;
mode = "free-scrolling";
}
}
function setViewportElement(element: HTMLElement | null) {
viewport = element;
// A visibility consumer may have subscribed before the viewport mounted,
// in which case observeVisibility() bailed out. Retry now it exists.
if (element) observeVisibility();
}
function setContentElement(element: HTMLElement | null) {
content = element;
}
function setSpacerElement(element: HTMLElement | null) {
spacer = element;
spacerGap = getRowGap(element?.parentElement ?? null);
}
function setPreserveScrollOnPrepend(value: boolean) {
preserveScrollOnPrepend = value;
}
function syncAfterScroll() {
commitScrollState();
scheduleVisibilitySync();
capturePrependAnchor();
}
function onAutoScrollChange() {
if (autoScroll() && mode === "following-bottom" && itemCount > 0) {
scrollToEnd({ behavior: "auto" });
return;
}
commitScrollState();
}
function destroy() {
if (stateFrame !== null) {
window.cancelAnimationFrame(stateFrame);
stateFrame = null;
}
if (visibilityFrame !== null) {
window.cancelAnimationFrame(visibilityFrame);
visibilityFrame = null;
}
if (autoscrollingTimeout !== null) {
window.clearTimeout(autoscrollingTimeout);
autoscrollingTimeout = null;
}
if (pendingScrollFrame !== null) {
window.cancelAnimationFrame(pendingScrollFrame);
pendingScrollFrame = null;
}
visibilityObserver?.disconnect();
visibilityObserver = null;
}
const context: MessageScrollerContext = {
get autoscrolling() {
return autoscrolling;
},
get scrollable() {
return scrollable;
},
get scrollableAttr() {
return scrollableAttr();
},
get visibility() {
return visibility;
},
acquireVisibility,
releaseVisibility,
handleContentChange,
handleResize,
scrollToEnd,
scrollToMessage,
scrollToStart,
setContentElement,
setSpacerElement,
setViewportElement,
setPreserveScrollOnPrepend,
syncAfterScroll,
userScrollIntent,
};
return {
context,
registerMessage,
applyDefaultScrollPosition,
onAutoScrollChange,
destroy,
};
}
// -----------------------------------------------------------------------------
// Provider wiring (provide/inject)
// -----------------------------------------------------------------------------
export function provideMessageScroller(props: MessageScrollerProviderProps) {
const engine = createEngine(props);
setContext(CONTEXT_KEY, engine.context);
setContext(REGISTER_KEY, engine.registerMessage);
let firstRun = true;
$effect(() => {
void (props.autoScroll ?? false);
if (firstRun) {
firstRun = false;
return;
}
engine.onAutoScrollChange();
});
onMount(() => {
engine.applyDefaultScrollPosition();
// The viewport element attaches in MessageScrollerViewport's onMount,
// after MessageScrollerContent ran its initial handleContentChange without
// it. Re-sync now that every element is wired up.
engine.context.syncAfterScroll();
});
onDestroy(() => engine.destroy());
return engine;
}
export function useMessageScrollerContext(): MessageScrollerContext {
const context = getContext<MessageScrollerContext | undefined>(CONTEXT_KEY);
if (!context)
throw new Error(
"useMessageScroller must be used within a MessageScroller.",
);
return context;
}
export function useMessageScrollerRegister(): RegisterMessage {
const register = getContext<RegisterMessage | undefined>(REGISTER_KEY);
if (!register)
throw new Error(
"MessageScrollerItem must be used within a MessageScroller.",
);
return register;
}
// -----------------------------------------------------------------------------
// Public hooks (call during component init). Reactive values are read through `.current`.
// -----------------------------------------------------------------------------
export function useMessageScroller() {
const { scrollToEnd, scrollToMessage, scrollToStart } =
useMessageScrollerContext();
return { scrollToEnd, scrollToMessage, scrollToStart };
}
export function useMessageScrollerScrollable(): {
readonly current: MessageScrollerScrollable;
} {
const context = useMessageScrollerContext();
return {
get current() {
return context.scrollable;
},
};
}
export function useMessageScrollerVisibility(): {
readonly current: MessageScrollerVisibilityState;
} {
const context = useMessageScrollerContext();
context.acquireVisibility();
onDestroy(context.releaseVisibility);
return {
get current() {
return context.visibility;
},
};
}
export { SCROLL_KEYS };
src/lib/registry/ui/message-scroller/index.ts
import Root from "./message-scroller.svelte";
import Button from "./message-scroller-button.svelte";
import Content from "./message-scroller-content.svelte";
import Item from "./message-scroller-item.svelte";
import Provider from "./message-scroller-provider.svelte";
import Viewport from "./message-scroller-viewport.svelte";
export {
type MessageScrollerButtonDirection,
type MessageScrollerDefaultScrollPosition,
type MessageScrollerProviderProps,
type MessageScrollerScrollAlign,
type MessageScrollerScrollable,
type MessageScrollerScrollOptions,
type MessageScrollerVisibilityState,
useMessageScroller,
useMessageScrollerScrollable,
useMessageScrollerVisibility,
} from "./use-message-scroller.svelte.js";
export {
Button,
Button as MessageScrollerButton,
Content,
Content as MessageScrollerContent,
Item,
Item as MessageScrollerItem,
Provider,
Provider as MessageScrollerProvider,
Root,
//
Root as MessageScroller,
Viewport,
Viewport as MessageScrollerViewport,
};
Part Prop Type Default
MessageScrollerProvider autoScroll boolean false
MessageScrollerItem messageId string none
MessageScrollerItem scrollAnchor boolean false
MessageScrollerButton direction start | end end
MessageScrollerButton variant, size Button props outline, sm

Built on @shadcn/react/message-scroller; the hooks useMessageScroller, useMessageScrollerScrollable and useMessageScrollerVisibility are re-exported.

  • The scroll button is a labeled pill (“Jump to latest”) instead of an icon-only button.