8.0k New

October 2026 - Components for Chat Interfaces

MessageScroller, Message, Bubble, Attachment, and Marker. Components for building chat interfaces.

Epicenter

Local-first, open source apps

Special Sponsor
New Chat

How can I help you today?

Morning, shadcn!
What are we working on today? Press send to start a new conversation
I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around.
Demo is read only. Press send to send messages.
<script lang="ts">
	import ArrowUpIcon from "@lucide/svelte/icons/arrow-up";
	import GlobeIcon from "@lucide/svelte/icons/globe";
	import ImageIcon from "@lucide/svelte/icons/image";
	import MessageCircleDashedIcon from "@lucide/svelte/icons/message-circle-dashed";
	import PaperclipIcon from "@lucide/svelte/icons/paperclip";
	import PlusIcon from "@lucide/svelte/icons/plus";
	import RotateCwIcon from "@lucide/svelte/icons/rotate-cw";
	import TelescopeIcon from "@lucide/svelte/icons/telescope";
	import { Chat } from "@ai-sdk/svelte";
	import * as Card from "$lib/components/ui/card/index.js";
	import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js";
	import * as Empty from "$lib/components/ui/empty/index.js";
	import * as InputGroup from "$lib/components/ui/input-group/index.js";
	import * as MessageScroller from "$lib/components/ui/message-scroller/index.js";
	import * as Tooltip from "$lib/components/ui/tooltip/index.js";
	import MessageAnimated from "$lib/components/message-animated.svelte";
	import { createChat, getMessageText } from "$lib/ai-sdk.js";
	import { Button } from "$lib/components/ui/button/index.js";
 
	const script = createChat()
		.user(
			"I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around."
		)
		.sleep(1000)
		.assistant(
			"That's the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll` — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.\n\nThe important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user's intent."
		)
		.user(
			"Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top."
		)
		.sleep(1000)
		.assistant(
			"MessageScrollerItem fixes that with turn anchoring. Set `scrollAnchor` on the turn that should settle near the top instead of blindly snapping to the document bottom.\n\nIt also leaves a small peek of the previous exchange visible above the anchor, so context isn't lost. The reply starts in view without that disorienting jump you get from a plain overflow container."
		)
		.user(
			"And if they've scrolled up to re-read an older answer? I don't want to yank them back down."
		)
		.sleep(1000)
		.assistant(
			"You won't. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out — their place in the thread stays put even as new tokens keep arriving below.\n\nWhen there is content they haven't seen yet, `MessageScrollerButton` appears at the bottom of the viewport. One tap jumps them back to the newest message and re-engages auto-scroll. Same pattern as Slack or iMessage: quiet when you're caught up, helpful when you're not."
		)
		.user("Last one — does this work with assistive tech?")
		.sleep(1000)
		.assistant(
			'`MessageScrollerContent` sets `role="log"` and `aria-relevant="additions"` by default, so screen readers announce new messages as they stream in.\n\nThe scroll button is a real `<button>` with an sr-only label, and it's removed from the tab order when you're already at the bottom — no ghost focus stops.'
		);
	const initialMessages = script.get(0);
	const transport = script.transport({ delayMs: 20 });
	const chat = new Chat({ messages: initialMessages, transport });
	const nextMessage = $derived(script.next(chat.messages));
	const isBusy = $derived(chat.status === "submitted" || chat.status === "streaming");
 
	function handleSubmit(event: SubmitEvent) {
		event.preventDefault();
		if (!nextMessage || isBusy) {
			return;
		}
		void chat.sendMessage(nextMessage);
	}
</script>
 
<MessageScroller.Provider>
	<div class="relative flex flex-col gap-4">
		<Card.Root class="mx-auto h-140 w-full max-w-sm gap-0">
			<Card.Header class="gap-1 border-b">
				<Card.Title>New Chat</Card.Title>
				<Card.Description>How can I help you today?</Card.Description>
				<Card.Action>
					<Tooltip.Root>
						<Tooltip.Trigger>
							{#snippet child({ props })}
								<Button
									{...props}
									variant="outline"
									size="icon"
									aria-label="Reset conversation"
									onclick={() => (chat.messages = initialMessages)}
									disabled={isBusy}
								>
									<RotateCwIcon />
								</Button>
							{/snippet}
						</Tooltip.Trigger>
						<Tooltip.Content>
							<p>Reset</p>
						</Tooltip.Content>
					</Tooltip.Root>
				</Card.Action>
			</Card.Header>
			<Card.Content class="flex-1 overflow-hidden p-0">
				{#if chat.messages.length === 0}
					<Empty.Root class="h-full">
						<Empty.Header>
							<Empty.Media variant="icon">
								<MessageCircleDashedIcon />
							</Empty.Media>
							<Empty.Title>Morning, shadcn!</Empty.Title>
							<Empty.Description>
								What are we working on today? Press send to start a new conversation
							</Empty.Description>
						</Empty.Header>
					</Empty.Root>
				{:else}
					<MessageScroller.Root>
						<MessageScroller.Viewport>
							<MessageScroller.Content aria-busy={isBusy} class="p-(--card-spacing)">
								{#each chat.messages as message (message.id)}
									<MessageAnimated {message} scrollAnchor={message.role === "user"} />
								{/each}
							</MessageScroller.Content>
						</MessageScroller.Viewport>
						<MessageScroller.Button />
					</MessageScroller.Root>
				{/if}
			</Card.Content>
			<Card.Footer class="flex-col gap-2">
				<form onsubmit={handleSubmit} class="w-full">
					<InputGroup.Root>
						<div class="h-14 w-full px-3 py-2.5">
							<span
								class="line-clamp-2 opacity-60 data-[status=ready]:opacity-100"
								data-status={chat.status}
							>
								{#if nextMessage}
									{getMessageText(nextMessage)}
								{:else}
									<span class="text-muted-foreground">
										No messages queued. Reset the conversation.
									</span>
								{/if}
							</span>
						</div>
						<InputGroup.Addon align="block-end" class="pt-1">
							<DropdownMenu.Root>
								<DropdownMenu.Trigger>
									{#snippet child({ props })}
										<InputGroup.Button
											{...props}
											aria-label="Add files"
											type="button"
											size="icon-sm"
											variant="outline"
										>
											<PlusIcon />
										</InputGroup.Button>
									{/snippet}
								</DropdownMenu.Trigger>
								<DropdownMenu.Content align="start" side="top" class="w-44">
									<DropdownMenu.Item>
										<PaperclipIcon />
										Add Photos & Files
									</DropdownMenu.Item>
									<DropdownMenu.Separator />
									<DropdownMenu.Item>
										<ImageIcon />
										Create Image
									</DropdownMenu.Item>
									<DropdownMenu.Item>
										<TelescopeIcon />
										Deep Research
									</DropdownMenu.Item>
									<DropdownMenu.Item>
										<GlobeIcon />
										Web Search
									</DropdownMenu.Item>
								</DropdownMenu.Content>
							</DropdownMenu.Root>
							<InputGroup.Button
								type="submit"
								variant="default"
								size="icon-sm"
								disabled={!nextMessage || isBusy}
								class="ml-auto"
							>
								<ArrowUpIcon />
								<span class="sr-only">Send</span>
							</InputGroup.Button>
						</InputGroup.Addon>
					</InputGroup.Root>
				</form>
			</Card.Footer>
		</Card.Root>
		<div class="px-0.5 text-center text-xs text-muted-foreground">
			Demo is read only. Press send to send messages.
		</div>
	</div>
</MessageScroller.Provider>

Today, we’re releasing a new set of components for building chat interfaces: MessageScroller, Message, Bubble, Attachment, and Marker.

This is the first phase of the chat components work. We’re taking it one piece at a time, reimagining the abstraction behind each part, and shipping them as shadcn-svelte components you can copy, compose, and adapt to your product.

We are starting with the conversation layer: scrolling, message rows, bubbles, attachments, and markers.

We asked ourselves: what makes a great streaming chat experience? Then we abstracted the core rules into a set of primitives: MessageScroller.

npx shadcn-svelte@latest add message-scroller message bubble attachment marker

MessageScroller

MessageScroller is the scroll container for a conversation. It handles the parts that are easy to get wrong: anchored turns, streamed replies, saved thread restore, prepended history, jump-to-message, scroll controls, and visibility tracking.

MessageScroller owns that behavior without owning your messages, AI state, transport, persistence, or model state. You bring the content renderer.

The MessageScroller is also available as an unstyled headless component in @shadcn-svelte/primitives.

Message, Bubble, Attachment, and Marker

The rest of the components cover the everyday pieces you need around the scroller.

  • Message lays out a row in the conversation with avatar, alignment, header, content, footer, and grouped messages.
  • Bubble renders the message surface, with variants, alignment, reactions, links, buttons, and collapsible content.
  • Attachment renders files and images with media, metadata, upload state, actions, and a full-card trigger that keeps actions separately clickable.
  • Marker renders status updates, system notes, bordered rows, and labeled separators for things like streaming state, tool activity, and date breaks.

They are intentionally small. Compose them together for AI chats, support inboxes, team threads, group chats, and product-specific conversations.

scroll-fade and shimmer

We also added two new CSS utilities for the details that make chat interfaces feel better.

scroll-fade adds scroll-aware edge fades to scroll containers. Use it on MessageScroller, ScrollArea, attachment rows, and any long list where you want to hint at more content without adding overlays or scroll listeners.

Item 1
Item 2
Item 3
Item 4
Item 5
Item 6
Item 7
Item 8
Item 9
Item 10
Item 11
Item 12
<script lang="ts">
	const items = Array.from({ length: 12 }, (_, index) => index + 1);
</script>
 
<div class="mx-auto w-full max-w-xs overflow-hidden rounded-2xl border">
	<div class="no-scrollbar h-72 scroll-fade overflow-y-auto">
		<div class="flex flex-col gap-1.5 p-1.5">
			{#each items as item (item)}
				<div class="rounded-lg bg-muted px-3 py-2.5 text-sm">
					Item {item}
				</div>
			{/each}
		</div>
	</div>
</div>

shimmer adds a text shimmer for live status. Use it for things like "Thinking…", "Generating response…", running tools, and streaming markers.

Generating response…

<p class="shimmer text-sm text-muted-foreground">Generating response&hellip;</p>

Both utilities ship with shadcn-svelte/tailwind.css, so projects initialized with npx shadcn-svelte@latest init already have them.

@shadcn-svelte/primitives

We also created @shadcn-svelte/primitives, a new package for unstyled, headless Svelte components.

The first primitive is @shadcn-svelte/primitives/message-scroller. The registry component wraps it with shadcn-svelte styles, but the scroll behavior lives in the package: anchoring, auto-follow, prepend preservation, scroll commands, and visibility.

This lets us ship behavior without locking it to a visual style. You still get copy-and-paste components that match your project, and the hard interaction logic stays tested in one place.

Available now for Svelte across all eight styles.