AI Chat
Chat panel built from Message and Bubble, with typing indicator and spring-in messages.
Assistant
online
Installation
npx shadcn@latest add @l8/ai-chatUsage
import { AiChatBlock } from '@/components/blocks/ai-chat';
export default function Page() {
return <AiChatBlock />;
}Source
"use client"
import * as React from "react"
import { AnimatePresence, motion } from "motion/react"
import { ArrowUpIcon, SparklesIcon } from "lucide-react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group"
import { Message, MessageAvatar, MessageContent } from "@/components/ui/message"
type ChatMessage = { id: number; role: "user" | "assistant"; text: string }
const replies = [
"Sure! I'd start with the Hero and Features blocks, then add Pricing below.",
"All blocks respect prefers-reduced-motion, so animations are skipped when users ask for that.",
"You can install any component with `npx shadcn add <name>` and tweak it in your repo.",
]
export function AiChatBlock() {
const [messages, setMessages] = React.useState<ChatMessage[]>([
{ id: 0, role: "assistant", text: "Hi! I'm your design system assistant. What are you building?" },
])
const [input, setInput] = React.useState("")
const [typing, setTyping] = React.useState(false)
const nextId = React.useRef(1)
const replyIndex = React.useRef(0)
const listRef = React.useRef<HTMLDivElement>(null)
// Scroll the list itself; scrollIntoView would also scroll the surrounding page.
React.useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" })
}, [messages, typing])
function send(e?: React.FormEvent) {
e?.preventDefault()
const text = input.trim()
if (!text || typing) return
setInput("")
const userMessage = { id: nextId.current++, role: "user" as const, text }
setMessages((m) => [...m, userMessage])
setTyping(true)
// Demo only: canned replies instead of a real model.
// Read refs outside the updater: StrictMode calls updaters twice.
const reply = { id: nextId.current++, role: "assistant" as const, text: replies[replyIndex.current++ % replies.length] }
setTimeout(() => {
setTyping(false)
setMessages((m) => [...m, reply])
}, 1400)
}
return (
<section className="flex justify-center px-6 py-12">
<div className="flex h-[30rem] w-full max-w-lg flex-col overflow-hidden rounded-xl border bg-card shadow-xl">
<header className="flex items-center gap-3 border-b px-4 py-3">
<div className="relative flex size-8 items-center justify-center rounded-full bg-primary text-primary-foreground">
<SparklesIcon className="size-4" />
<span className="absolute -right-0.5 -bottom-0.5 size-2.5 rounded-full border-2 border-card bg-emerald-500" />
</div>
<div>
<div className="text-sm font-medium">Assistant</div>
<div className="text-xs text-muted-foreground">{typing ? "typing…" : "online"}</div>
</div>
</header>
<div ref={listRef} className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
<AnimatePresence initial={false}>
{messages.map((m) => (
<motion.div
key={m.id}
layout
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
style={{ originX: m.role === "user" ? 1 : 0 }}
>
<Message align={m.role === "user" ? "end" : "start"}>
{m.role === "assistant" && (
<MessageAvatar className="size-7 bg-primary text-primary-foreground">
<SparklesIcon className="size-3.5" />
</MessageAvatar>
)}
<MessageContent>
<Bubble variant={m.role === "user" ? "default" : "muted"}>
<BubbleContent>{m.text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
</motion.div>
))}
{typing && (
<motion.div
key="typing"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="flex w-fit gap-1 rounded-xl bg-muted px-3 py-3"
aria-label="Assistant is typing"
>
{[0, 1, 2].map((i) => (
<motion.span
key={i}
className="size-1.5 rounded-full bg-muted-foreground"
animate={{ y: [0, -4, 0], opacity: [0.4, 1, 0.4] }}
transition={{ duration: 0.8, repeat: Infinity, delay: i * 0.15 }}
/>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<form onSubmit={send} className="border-t p-3">
<InputGroup className="rounded-xl">
<InputGroupTextarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
send()
}
}}
placeholder="Ask something…"
aria-label="Message"
rows={1}
className="min-h-0"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton type="submit" variant="default" size="icon-xs" className="rounded-full" disabled={!input.trim() || typing} aria-label="Send">
<ArrowUpIcon />
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</form>
</div>
</section>
)
}