Files
shadcn-ui/apps/v4/registry/bases/base/examples/message-scroller-example.tsx
shadcn 18fcf0f766 feat: @shadcn/react (#11022)
* feat(@shadcn/react): add message-scroller package

Add the @shadcn/react headless primitives package with MessageScroller
scroll anchoring, streaming follow, history prepend, and jump-to-message
behavior. Includes geometry helpers, use-render utility, and unit,
browser, and perf tests.

* feat(registry): add chat components

Add MessageScroller, Message, Bubble, Attachment, and Marker registry
sources for base and radix, style variants, preview-03 chat blocks,
and registry index wiring.

* feat(v4): integrate chat components into docs site

Wire chat components into the v4 app with docs routes, example preview
pages, message part renderers, markdown support, registry build updates,
and supporting lib utilities.

* feat(examples): add chat component demos

Add base and radix example demos for MessageScroller, Message, Bubble,
Attachment, Marker, scroll-fade, and shimmer.

* docs: add chat component documentation

Add component and utility docs for the chat component set, update docs
navigation, and add the June 2026 chat components changelog entry.

* chore: regenerate registry JSON output

Rebuild public registry artifacts for all style variants with the new
chat components.

* chore(release): add @shadcn/react publish and CI pipeline

Add Changesets prerelease workflow, browser test job, RELEASING docs,
and monorepo wiring for publishing @shadcn/react independently from
the shadcn CLI.

* docs: fix display of component preview on mobile

* fix

* fix

* docs: add message scroller docs

* style: format

* fix
2026-06-26 21:19:31 +04:00

253 lines
8.8 KiB
TypeScript

"use client"
import * as React from "react"
import { useChat } from "@ai-sdk/react"
import { createChat, getMessageText } from "@/lib/ai"
import {
Example,
ExampleWrapper,
} from "@/registry/bases/base/components/example"
import { Bubble, BubbleContent } from "@/registry/bases/base/ui/bubble"
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/registry/bases/base/ui/card"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@/registry/bases/base/ui/input-group"
import {
Marker,
MarkerContent,
MarkerIcon,
} from "@/registry/bases/base/ui/marker"
import { Message, MessageContent } from "@/registry/bases/base/ui/message"
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerItem,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@/registry/bases/base/ui/message-scroller"
import { Spinner } from "@/registry/bases/base/ui/spinner"
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
export default function MessageScrollerExample() {
return (
<ExampleWrapper>
<MessageScrollerDemo />
</ExampleWrapper>
)
}
const chat = createChat()
.user("Hello there!")
.sleep(2000)
.assistant("Hey, how's it going?")
.user(
`I'm prototyping an AI chat surface for our product docs.
Can you sketch a sensible component breakdown and call out anything I'd regret baking into v1?`
)
.sleep(2000)
.assistant(
`## Recommended layout
Treat the chat as three layers so scrolling and the composer never fight each other:
| Layer | Responsibility |
| --- | --- |
| **Shell** | Card or page frame, title, status indicator |
| **Transcript** | \`overflow-y-auto\` message list |
| **Composer** | Input group, send, stop |
### Message rendering
- **User messages**: right-aligned, muted background, \`max-w-[80%]\`.
- **Assistant messages**: full width within the column; use \`whitespace-pre-wrap\` for text.
### Suggested component split
\`\`\`tsx
<ChatCard>
<ChatHeader status={status} />
<ChatTranscript messages={messages} />
<ChatComposer onSubmit={sendMessage} onStop={stop} />
</ChatCard>
\`\`\`
### v1 pitfalls to avoid
1. **Inlining transport logic in the UI** — keep scripted/demo transport beside your example, not inside shared components.
2. **Fixed transcript height without \`overflow-hidden\`** — parent must clip, child must scroll, or the composer jumps.
3. **Not giving the scroller a ref or auto-scroll flag** — long replies should stay pinned to the bottom unless the user scrolls up.
### Practical next step
Start with plain \`whitespace-pre-wrap\` text to validate spacing and scroll behavior. Once the transcript feels right, you can swap assistant text rendering to a richer renderer if you need it.
Want me to walk through auto-scroll vs. manual scroll-to-bottom next?`
)
.user(
"What about message spacing when one reply is short and the next is really long?"
)
.sleep(2000)
.assistant(
`Good question. The scroll container should own vertical rhythm, not individual bubbles.
Use a consistent \`gap\` on the message list (something like \`gap-4\` or \`gap-6\`) so short and long messages sit on the same grid. For assistant replies that span many paragraphs, keep everything in one bubble rather than splitting on every line break — otherwise the transcript feels choppy when you're skimming.
When a long reply streams in, pin the viewport to the bottom with \`MessageScroller autoScroll\` until the user scrolls up. If they've scrolled away, show a scroll-to-bottom affordance instead of yanking them back mid-read.
That's usually enough for v1. Fancy diff animations or per-paragraph fade-ins can wait until you've validated that the base scroll + composer layout holds up on real devices.`
)
.user("Thanks, that helps.")
.sleep(1000)
.assistant(
"Happy to help — send another message when you're ready to keep stepping through the demo."
)
const initialMessages = chat.get({ count: 0 })
const transport = chat.transport({
chunkDelayMs: 10,
})
function MessageScrollerDemo() {
const { messages, sendMessage, status, stop } = useChat({
messages: initialMessages,
transport,
})
const nextMessage = chat.next({ after: messages })
const isBusy = status === "submitted" || status === "streaming"
function handleFormSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (!nextMessage || isBusy) {
return
}
void sendMessage(nextMessage)
}
return (
<Example title="Chat" containerClassName="self-start">
<Card className="h-140">
<CardHeader>
<CardTitle>How can I help you today?</CardTitle>
<CardDescription>Status: {status}</CardDescription>
</CardHeader>
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
<MessageScrollerProvider>
<MessageScroller>
<MessageScrollerViewport>
<MessageScrollerContent className="p-(--card-spacing)">
{messages.map((message) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={message.role === "user"}
>
<Message
align={message.role === "user" ? "end" : "start"}
>
<MessageContent>
{message.parts.map((part, index) => {
if (part.type !== "text") {
return null
}
return (
<Bubble
key={`${message.id}-${index}`}
variant={
message.role === "user" ? "default" : "muted"
}
>
<BubbleContent className="whitespace-pre-wrap">
{part.text}
</BubbleContent>
</Bubble>
)
})}
</MessageContent>
</Message>
</MessageScrollerItem>
))}
{status === "submitted" ? (
<MessageScrollerItem scrollAnchor={false}>
<Marker role="status">
<MarkerIcon>
<Spinner />
</MarkerIcon>
<MarkerContent>Thinking...</MarkerContent>
</Marker>
</MessageScrollerItem>
) : null}
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton />
</MessageScroller>
</MessageScrollerProvider>
</CardContent>
<CardFooter>
<form onSubmit={handleFormSubmit} className="w-full" id="chat-form">
<InputGroup>
<InputGroupTextarea
placeholder="Ask me anything..."
className="h-10 min-h-10 overflow-y-auto"
value={
isBusy ? "" : nextMessage ? getMessageText(nextMessage) : ""
}
readOnly
/>
<InputGroupAddon align="block-end" className="p-2">
<InputGroupButton
variant="default"
size="icon-sm"
type="submit"
disabled={!nextMessage || isBusy}
className="ml-auto data-[hidden=true]:hidden"
data-hidden={isBusy}
>
<IconPlaceholder
lucide="ArrowUpIcon"
tabler="IconArrowUp"
hugeicons="ArrowUp02Icon"
phosphor="ArrowUpIcon"
remixicon="RiArrowUpLine"
/>
<span className="sr-only">Send</span>
</InputGroupButton>
<InputGroupButton
size="icon-sm"
type="button"
data-hidden={!isBusy}
className="ml-auto data-[hidden=true]:hidden"
onClick={() => stop()}
>
<IconPlaceholder
lucide="StopCircleIcon"
tabler="IconPlayerStop"
hugeicons="StopCircleIcon"
phosphor="StopCircleIcon"
remixicon="RiStopCircleLine"
/>
<span className="sr-only">Stop</span>
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
</CardFooter>
</Card>
</Example>
)
}