Skip to content
AI chat

MessageScroller

The transcript: follows new output, never moves you while you read back.

import { MessageScroller } from "@flinbu/seed";

A conversation with Pluto

Send a message: your turn scrolls near the top and the reply grows below it.

Today
What changed in v2.4?
Eight tokens moved: the accent ramp is one step lighter in Dark, and two radius tokens were renamed. Nothing was removed.
  1. Read tokens.json435 tokens
  2. Drafting a proposal, In progress
Checking 435 tokens
import { Button, InputGroup, Marker, Message, MessageScroller, Trace } from "@flinbu/seed";
import { ArrowUp } from "lucide-react";

const [turns, setTurns] = React.useState<{ id: number; from: "user" | "assistant"; text: string }[]>([
  { id: 1, from: "user", text: "What changed in v2.4?" },
  { id: 2, from: "assistant", text: "Eight tokens moved: the accent ramp is one step lighter in Dark, and two radius tokens were renamed. Nothing was removed." },
]);
const [draft, setDraft] = React.useState("");
const send = () => {
  const t = draft.trim(); if (!t) return;
  const id = turns.length + 1;
  setTurns((p) => [...p, { id, from: "user", text: t }]);
  setDraft("");
  setTimeout(() => setTurns((p) => [...p, { id: id + 1, from: "assistant", text: "I'll check that and propose a change for you to approve." }]), 600);
};

<div className="flex h-[min(32rem,80dvh)] w-full max-w-xl flex-col overflow-hidden rounded-petal rounded-tl-petal-tip border border-secondary bg-primary">
  <MessageScroller className="flex-1">
    <Marker variant="separator">Today</Marker>
    {turns.map((t) => <Message key={t.id} from={t.from} actions={t.from === "assistant" ? actions : undefined}>{t.text}</Message>)}
    <Trace summary="Pluto checked your Dark theme · 6s" steps={STEPS} />
    <Marker status="running">Checking 435 tokens</Marker>
  </MessageScroller>
  <div className="border-t border-secondary p-sm">
    <InputGroup multiline rows={2} aria-label="Message Pluto" placeholder="Ask Pluto…" value={draft} onChange={(e) => setDraft(e.target.value)}
      onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }}
      bottom={<Button size="sm" icon={<ArrowUp />} aria-label="Send" disabled={!draft.trim()} onClick={send} />} />
  </div>
</div>

Use it for

  • Any chat with streamed answers.

Use something else for

  • Putting the composer inside it: keep it below.

Props

PropTypeDefaultDescription
autoScrollbooleantrueFollow output at the bottom.
peeknumber48Previous turn left visible (px).
defaultScrollPosition'end''start''end'Where it starts.
heightnumberstring–Or give the parent a height.
jumpLabelstring'Jump to latest'Button text.

Accessibility

  • A focusable log announced politely.
  • Scrolling up by wheel, touch or keyboard stops following; 'Jump to latest' brings you back.