caliper uidocs

Message scroller

Free

A scrolling transcript that stays pinned to the newest message.

Installs components/ui/message-scroller.tsxDepends on lucide-reactMotion honours reduced motion
pnpm dlx shadcn@latest add https://caliperui.site/r/message-scroller.json

Assistant

Ask me about your projects, like which one needs attention first.

When to use #

Use it for

  • A conversation that grows as replies stream in.
  • You want a "jump to latest" control when the user scrolls up.

Skip it when

  • The list is a fixed page of content.

Installation #

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

Usage #

This is the code behind the preview above.

example.tsx
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>
  );
}

API reference #

MessageScroller

component
PropTypeDefaultDescription
label *string—
children *ReactNode—
followKeyunknown—Changing this (for example when you send a message) always brings the newest content into view.
announcementstring—Short status for screen readers, such as "Assistant is responding". Content itself is not announced as it streams.
classNamestring—

Accessibility #

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.

Motion #

It checks prefers-reduced-motion itself and settles immediately instead of travelling. Every state is still reachable.