Assistant
Ask me about your projects, like which one needs attention first.
A scrolling transcript that stays pinned to the newest message.
pnpm dlx shadcn@latest add https://caliperui.site/r/message-scroller.jsonAssistant
pnpm dlx shadcn@latest add https://caliperui.site/r/message-scroller.json
Adds its 2 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.
This is the code behind the preview above.
import { useEffect, useRef, useState } from "react" import { Message } from "@/components/ui/message" import { MessageScroller } from "@/components/ui/message-scroller" type Turn = { id: string; role: "user" | "assistant"; text: string; status?: "pending" | "complete" }; const answers = [ "Here is a short answer. Start with the smallest change, check it with the keyboard, then add polish.", "Good question. I would keep one source of truth and let the components stay controlled.", "That depends on the screen. If you tell me where it lives, I can be more specific.", ]; function useScriptedChat(initial: Turn[]) { const [turns, setTurns] = useState(initial); const n = useRef(0); const timers = useRef<ReturnType<typeof setTimeout>[]>([]); useEffect(() => () => timers.current.forEach(clearTimeout), []); const send = (text: string) => { const id = n.current++; setTurns((t) => [...t, { id: `u${id}-${t.length}`, role: "user", text }, { id: `a${id}-${t.length}`, role: "assistant", text: "", status: "pending" }]); timers.current.push( setTimeout(() => setTurns((t) => t.map((x, i) => (i === t.length - 1 && x.status === "pending" ? { ...x, text: answers[id % answers.length], status: "complete" } : x))), 1100), ); }; return [turns, send, setTurns] as const; } function Turns({ turns }: { turns: Turn[] }) { return ( <> {turns.map((t) => ( <Message key={t.id} role={t.role} name={t.role === "assistant" ? "Assistant" : undefined} status={t.status === "pending" ? "pending" : "complete"}> {t.text || null} </Message> ))} </> ); } export function MessageScrollerDemo() { const [turns] = useScriptedChat([{ id: "s0", role: "assistant", text: "Ask me about your projects, like which one needs attention first.", status: "complete" }]); return ( <MessageScroller label="Assistant conversation" followKey={turns.length}> <Turns turns={turns} /> </MessageScroller> ); }
It exposes log and status semantics, with aria-label, aria-live and aria-hidden kept in step with state. Changes that matter are announced through a live region.
No custom key handling; Tab and the native activation keys do the work.
It checks prefers-reduced-motion itself and settles immediately instead of travelling. Every state is still reachable.