l8components

AI Chat

Chat panel built from Message and Bubble, with typing indicator and spring-in messages.

Assistant
online
Hi! I'm your design system assistant. What are you building?

Installation

npx shadcn@latest add @l8/ai-chat

Usage

import { AiChatBlock } from '@/components/blocks/ai-chat';

export default function Page() {
  return <AiChatBlock />;
}

Source

components/blocks/ai-chat.tsx
"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>
  )
}

On this page