MessageScroller
A transcript that follows a streaming answer, and lets go the moment you scroll up.
- Today
Scroll up while it writes: it stays where you leave it.
The idea
The transcript moves only when the reader asks it to: being at the bottom asks, and scrolling up — a few pixels of wheel is enough — says stop, so it never drags anyone back down mid-sentence. While you are away, a glass pill fades up with the count of turns you have missed. History loaded above keeps your place, measured and corrected in the same frame before paint, which is what overflow-anchor does where it exists, done by hand so Safari gets it too.
Composes ChatMessage and Marker.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { useRef } from "react";
import { ChatMessage, ChatThread, MessageScroller, type MessageScrollerHandle } from "mihirmodi-ui";
const scroller = useRef<MessageScrollerHandle>(null);
// The frame needs a height; the scroller fills it and follows the newest
// turn until the reader scrolls up.
<MessageScroller
ref={scroller}
count={messages.length}
streaming={status === "streaming"}
onReachTop={loadOlder} // prepended turns keep the reader's place
className="h-[70vh]"
contentClassName="px-4 pb-16" // room for the "Jump to latest" pill
>
<ChatThread>
{messages.map((m) => (
<ChatMessage key={m.id} from={m.role}>{m.text}</ChatMessage>
))}
</ChatThread>
</MessageScroller>
// A citation links back to the question it answers: give that turn
// data-message-id (or an id) and scroll to it.
scroller.current?.scrollToMessage("q-42");Props
| Prop | Type | Notes |
|---|---|---|
| countnumber | number | How many turns there are. Turns that arrive while the reader is away become the pill's count. |
| streamingboolean | boolean= false | Sets `aria-busy` on the log, so the finished turn is announced rather than every token. |
| onReachTop() => void | () => void | The reader scrolled into the top 48px. Prepend older turns; their place is kept. |
| thresholdnumber | number= 24 | How close to the bottom, scrolling down, picks the follow back up. |
| refMessageScrollerHandle | MessageScrollerHandle | `scrollToBottom()`, `scrollToMessage(id)` — the element with `data-message-id` or `id` — and `isFollowing()`. |
| classNamestring | string | On the frame. Give it a height; the scroller fills it. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { CLOSE, EASE_OUT_EXPO, FLAT, SLIDE } from "../../lib/motion.js";
const useIso = typeof window === "undefined" ? useEffect : useLayoutEffect;
export type MessageScrollerHandle = {
/** Back to the newest turn, and follow it again. */
scrollToBottom: (opts?: { smooth?: boolean }) => void;
/**
* Bring a turn to the top of the viewport. The turn is the element inside the
* scroller carrying `data-message-id={id}`, or else `id={id}`. Returns false
* when there is no such turn.
*/
scrollToMessage: (id: string, opts?: { smooth?: boolean; offset?: number }) => boolean;
/** True while the scroller is following the newest turn. */
isFollowing: () => boolean;
};
export type MessageScrollerProps = {
children: ReactNode;
/**
* How many turns there are. When it rises while the reader is scrolled away,
* the difference is the count on the "Jump to latest" pill.
*/
count?: number;
/** A response is streaming. Sets `aria-busy` so the log announces the finished turn, not every token. */
streaming?: boolean;
/** How close to the bottom, in px, still counts as at the bottom when scrolling down to it. */
threshold?: number;
/** Called when the reader scrolls up into the top 48px — load older history here. Their place is kept as it arrives. */
onReachTop?: () => void;
/** Called when following starts or stops. */
onFollowChange?: (following: boolean) => void;
/** The pill's words. */
jumpLabel?: ReactNode;
/** On the outer frame. Give it a height: the scroller fills it. */
className?: string;
/** On the content inside the scroll viewport — padding for the thread. */
contentClassName?: string;
"aria-label"?: string;
};
/**
* The scroll container for a conversation.
*
* It follows the newest turn while a response streams, and lets go the moment
* the reader moves up — a few pixels of wheel are enough, so it never drags
* anyone back down mid-sentence. Coming back to the bottom by hand picks the
* follow up again. The rule is that the transcript only moves when the reader
* asked it to: reaching the bottom asks, scrolling up says stop.
*
* While unstuck, a glass pill fades up from the foot of the frame with "Jump
* to latest" and a count of the turns that arrived since. It is the one piece
* of chrome here, so it floats over the text rather than taking a row of its
* own, and it is inert while hidden so it never becomes a stray tab stop.
*
* History that arrives above keeps the reader's place: the turn at the top of
* the viewport is measured before and after, and the difference is scrolled
* away in the same frame, before paint. That is what `overflow-anchor` does in
* the engines that have it — done by hand so Safari gets it too, and turned
* off natively so the two never compensate twice.
*/
export const MessageScroller = forwardRef<MessageScrollerHandle, MessageScrollerProps>(function MessageScroller(
{
children,
count,
streaming = false,
threshold = 24,
onReachTop,
onFollowChange,
jumpLabel = "Jump to latest",
className = "",
contentClassName = "",
"aria-label": ariaLabel = "Messages",
},
ref
) {
const reduce = useReducedMotion();
const viewport = useRef<HTMLDivElement>(null);
const content = useRef<HTMLDivElement>(null);
const following = useRef(true);
const [stuck, setStuck] = useState(true);
const lastTop = useRef(0);
const anchors = useRef<{ el: Element; top: number }[]>([]);
const atTop = useRef(false);
const baseline = useRef(count ?? 0);
const [unseen, setUnseen] = useState(0);
const [overflows, setOverflows] = useState(false);
const follow = useCallback(
(on: boolean) => {
if (following.current === on) return;
following.current = on;
setStuck(on);
onFollowChange?.(on);
},
[onFollowChange]
);
/* The first few turns at the top of the viewport, and where they sit —
measured on every scroll so a later change above them can be undone. More
than one, because the top one is often the very thing being replaced (a
"Show earlier" button). Turns are the elements carrying data-message-id;
failing that, the content's grandchildren (a ChatThread's <li>s), then its
children. */
const measureAnchor = useCallback(() => {
const vp = viewport.current;
const ct = content.current;
if (!vp || !ct) return;
let items: NodeListOf<Element> = ct.querySelectorAll("[data-message-id]");
if (!items.length) items = ct.querySelectorAll(":scope > * > *");
if (!items.length) items = ct.querySelectorAll(":scope > *");
const vpTop = vp.getBoundingClientRect().top;
const found: { el: Element; top: number }[] = [];
for (const el of items) {
const r = el.getBoundingClientRect();
if (r.bottom > vpTop) found.push({ el, top: r.top - vpTop });
if (found.length === 3) break;
}
anchors.current = found;
}, []);
const toBottom = useCallback(
(smooth: boolean) => {
const vp = viewport.current;
if (!vp) return;
const top = vp.scrollHeight - vp.clientHeight;
if (smooth && !reduce) vp.scrollTo({ top, behavior: "smooth" });
else vp.scrollTop = top;
},
[reduce]
);
// Open at the newest turn.
useIso(() => {
toBottom(false);
if (viewport.current) lastTop.current = viewport.current.scrollTop;
measureAnchor();
}, []);
// Content grows (a streamed token, a new turn, an image loading) or history is prepended.
useEffect(() => {
const vp = viewport.current;
const ct = content.current;
if (!vp || !ct || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => {
setOverflows(vp.scrollHeight - vp.clientHeight > 1);
if (following.current) {
toBottom(false);
} else {
const a = anchors.current.find((x) => x.el.isConnected);
if (a) {
const delta = a.el.getBoundingClientRect().top - vp.getBoundingClientRect().top - a.top;
if (Math.abs(delta) > 0.5) vp.scrollTop += delta;
}
}
lastTop.current = vp.scrollTop;
measureAnchor();
});
ro.observe(ct);
ro.observe(vp);
return () => ro.disconnect();
}, [toBottom, measureAnchor]);
const onScroll = () => {
const vp = viewport.current;
if (!vp) return;
const top = vp.scrollTop;
const dist = vp.scrollHeight - vp.clientHeight - top;
const movedUp = top < lastTop.current - 0.5;
const movedDown = top > lastTop.current + 0.5;
lastTop.current = top;
// At the very bottom always follows (it also covers content shrinking under
// a follower, which clamps scrollTop upward). Otherwise: up lets go, down
// into the threshold picks it back up.
if (dist <= 1 || (movedDown && dist <= threshold)) follow(true);
else if (movedUp) follow(false);
if (top < 48 && movedUp && !atTop.current) {
atTop.current = true;
onReachTop?.();
} else if (top >= 48) {
atTop.current = false;
}
measureAnchor();
};
// Count turns that arrive while the reader is away.
useEffect(() => {
if (count === undefined) return;
if (stuck) {
baseline.current = count;
setUnseen(0);
} else {
setUnseen(Math.max(0, count - baseline.current));
}
}, [count, stuck]);
useImperativeHandle(
ref,
() => ({
scrollToBottom: (opts) => {
follow(true);
toBottom(opts?.smooth ?? true);
},
scrollToMessage: (id, opts) => {
const vp = viewport.current;
const ct = content.current;
if (!vp || !ct) return false;
const sel = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(id) : id;
const el = ct.querySelector(`[data-message-id="${sel}"]`) ?? ct.querySelector(`#${sel}`);
if (!el) return false;
const top = el.getBoundingClientRect().top - vp.getBoundingClientRect().top + vp.scrollTop - (opts?.offset ?? 16);
// Going somewhere on purpose is not following, unless it lands at the bottom.
follow(false);
vp.scrollTo({ top: Math.max(0, top), behavior: (opts?.smooth ?? true) && !reduce ? "smooth" : "auto" });
return true;
},
isFollowing: () => following.current,
}),
[follow, toBottom, reduce]
);
const shown = !stuck && overflows;
// Hidden only once the fade-out has run, so leaving is seen as well as arriving.
const [gone, setGone] = useState(true);
useEffect(() => {
if (shown) setGone(false);
}, [shown]);
return (
<LazyMotion features={domAnimation} strict>
<div className={`relative flex min-h-0 flex-col ${className}`}>
<div
ref={viewport}
onScroll={onScroll}
role="region"
aria-label={ariaLabel}
tabIndex={0}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain [overflow-anchor:none]"
>
<div ref={content} role="log" aria-relevant="additions" aria-busy={streaming || undefined} className={contentClassName}>
{children}
</div>
</div>
{/* The wrapper moves; the glass on the button holds still, so its blur survives. */}
<m.div
aria-hidden={!shown}
inert={!shown}
className={`pointer-events-none absolute inset-x-0 bottom-3 flex justify-center ${gone ? "invisible" : ""}`}
initial={false}
animate={shown ? { opacity: 1, y: 0 } : { opacity: 0, y: reduce ? 0 : 6 }}
transition={
reduce ? FLAT : shown ? { y: SLIDE, opacity: { duration: 0.2, ease: EASE_OUT_EXPO } } : CLOSE
}
onAnimationComplete={() => !shown && setGone(true)}
>
<button
type="button"
tabIndex={shown ? 0 : -1}
onClick={() => {
follow(true);
toBottom(true);
viewport.current?.focus({ preventScroll: true });
}}
className="glass-nav dock-shadow pointer-events-auto inline-flex h-8 items-center gap-1.5 rounded-full border-[0.5px] border-gray-alpha-400 pl-3 pr-2.5 text-[12px] font-medium text-gray-1000 transition-transform duration-150 ease-geist active:scale-[0.97] motion-reduce:transition-none"
>
{jumpLabel}
{unseen > 0 && (
<span className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-ink px-1.5 text-[11px] tabular-nums text-background-100">
{unseen}
<span className="sr-only"> new</span>
</span>
)}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden className="h-3.5 w-3.5 text-muted">
<path d="M12 5v14M6 13l6 6 6-6" />
</svg>
</button>
</m.div>
</div>
</LazyMotion>
);
});
/**
* The motion presets the newer components share. The older ones (Dock,
* DropMenu, HighlightList) carry their own copies of the same numbers, which is
* deliberate: each of them documents why its curve is what it is, in place.
*/
/** One slide for anything that re-targets its position. */
export const SLIDE = { type: "spring", bounce: 0.2, duration: 0.4 } as const;
/** Something arriving that should land with a small settle. */
export const LIFT = { type: "spring", bounce: 0.28, duration: 0.5 } as const;
/** The disagreeing pair behind squash-and-stretch: see DropMenu. */
export const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
export const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;
/** Closing is quick and flat. A bounce on the way out reads as indecision. */
export const CLOSE = { duration: 0.13, ease: "easeIn" } as const;
/** Under reduced motion: a short plain fade, nothing that travels. */
export const FLAT = { duration: 0.15 } as const;
/** Expo-out, for anything that also drives a blur — it must not overshoot. */
export const EASE_OUT_EXPO = [0.16, 1, 0.3, 1] as const;
/** The house curve: overshoots a touch, for transforms only. */
export const EASE_GEIST = [0.175, 0.885, 0.32, 1.1] as const;
/** A block entering a list: fade and 4px. Opacity never springs. */
export function enter(reduce: boolean | null) {
return {
initial: reduce ? { opacity: 0 } : { opacity: 0, y: 4 },
animate: { opacity: 1, y: 0 },
transition: reduce ? FLAT : { duration: 0.2, ease: EASE_GEIST },
};
}