# Chat Bot

> Simulated AI chatbot with animated typing, message bubbles, a three-dot thinking indicator, and a looping Q&A exchange. No avatars â€” pure bubble UI.

A shadcn/ui block from the [@uiception](https://uiception.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uiception/chat-bot).

- Page, with a live preview: https://shadcn.exchange/r/uiception/chat-bot
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uiception/chat-bot-light-fed9f2ca526065d4.png
- Registry item: https://uiception.com/r/chat-bot.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uiception.com/r/chat-bot.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `registry/new-york/blocks/chat-bot/page.tsx`

```tsx
import ChatBot from "./components/chat-bot"

export default function Page() {
  return (
    <div className="px-4 py-10 md:px-6 md:py-14">
      <ChatBot />
    </div>
  )
}
```

### `registry/new-york/blocks/chat-bot/components/chat-bot.tsx`

```tsx
"use client"

import { cn } from "@/lib/utils"
import {
  RotateCcw,
  Copy,
  Share2,
  ThumbsUp,
  ThumbsDown,
  CornerDownRight,
} from "lucide-react"
import { useChatAnimation } from "../hooks/use-chat-animation"
import MessageBubble from "./message-bubble"
import ThinkLabel from "./typing-indicator"
import ChatInput from "./chat-input"
import { SUGGESTIONS } from "../lib/config"
import "../styles/chat-bot.css"

export default function ChatBot({ className }: { className?: string }) {
  const { phase, inputText, userMessage, botText, thinkSeconds, settled } =
    useChatAnimation()
  const showMessages = userMessage !== null
  const isPaused = phase === "paused"

  return (
    <div
      className={cn(
        "cb-root mx-auto flex w-full max-w-3xl min-w-0 justify-center px-6 py-12",
        className
      )}
    >
      <div className="flex w-full max-w-[600px] flex-col gap-7">
        <div
          className={cn(
            "pointer-events-none flex translate-y-5 flex-col gap-6 opacity-0 transition-[opacity,transform] duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] motion-reduce:transition-none",
            showMessages && "pointer-events-auto translate-y-0 opacity-100"
          )}
        >
          {userMessage && <MessageBubble role="user" text={userMessage} />}

          <div className="flex flex-col gap-2.5">
            <ThinkLabel phase={phase} thinkSeconds={thinkSeconds} />

            {botText !== null && (
              <MessageBubble
                role="bot"
                text={botText}
                isTyping={phase === "botTyping"}
              />
            )}

            <div className={cn("cb-reveal", isPaused && "cb-reveal--open")}>
              <div className="cb-reveal-inner">
                {isPaused && (
                  <>
                    <div className="flex items-center gap-0.5">
                      <button
                        type="button"
                        className="cb-action-btn flex size-9 cursor-default items-center justify-center rounded-2.5 border-none bg-transparent text-(--cb-action-color) transition-[color,background] duration-150 hover:bg-(--cb-action-hover-bg) hover:text-(--cb-action-hover-color)"
                        aria-label="Retry"
                      >
                        <RotateCcw size={14} />
                      </button>
                      <button
                        type="button"
                        className="cb-action-btn flex size-9 cursor-default items-center justify-center rounded-2.5 border-none bg-transparent text-(--cb-action-color) transition-[color,background] duration-150 hover:bg-(--cb-action-hover-bg) hover:text-(--cb-action-hover-color)"
                        aria-label="Copy"
                      >
                        <Copy size={14} />
                      </button>
                      <button
                        type="button"
                        className="cb-action-btn flex size-9 cursor-default items-center justify-center rounded-2.5 border-none bg-transparent text-(--cb-action-color) transition-[color,background] duration-150 hover:bg-(--cb-action-hover-bg) hover:text-(--cb-action-hover-color)"
                        aria-label="Share"
                      >
                        <Share2 size={14} />
                      </button>
                      <button
                        type="button"
                        className="cb-action-btn flex size-9 cursor-default items-center justify-center rounded-2.5 border-none bg-transparent text-(--cb-action-color) transition-[color,background] duration-150 hover:bg-(--cb-action-hover-bg) hover:text-(--cb-action-hover-color)"
                        aria-label="Thumbs up"
                      >
                        <ThumbsUp size={14} />
                      </button>
                      <button
                        type="button"
                        className="cb-action-btn flex size-9 cursor-default items-center justify-center rounded-2.5 border-none bg-transparent text-(--cb-action-color) transition-[color,background] duration-150 hover:bg-(--cb-action-hover-bg) hover:text-(--cb-action-hover-color)"
                        aria-label="Thumbs down"
                      >
                        <ThumbsDown size={14} />
                      </button>
                    </div>
                    <div className="flex flex-col gap-1.5">
                      {SUGGESTIONS.map((s) => (
                        <button
                          key={s}
                          type="button"
                          className="cb-chip flex w-fit cursor-default items-center gap-2 rounded-3 border border-(--cb-chip-border) bg-transparent px-2.5 py-2 pr-3.5 text-left text-[11px] text-(--cb-chip-text) transition-[border-color] duration-150 hover:border-(--cb-chip-border)"
                        >
                          <CornerDownRight
                            size={12}
                            className="shrink-0 text-(--cb-chip-icon)"
                            aria-hidden
                          />
                          {s}
                        </button>
                      ))}
                    </div>
                  </>
                )}
              </div>
            </div>
          </div>
        </div>

        <ChatInput value={inputText} phase={phase} settled={settled} />
      </div>
    </div>
  )
}
```

### `registry/new-york/blocks/chat-bot/components/message-bubble.tsx`

```tsx
interface MessageBubbleProps {
  role: "user" | "bot"
  text: string
  isTyping?: boolean
}

export default function MessageBubble({
  role,
  text,
  isTyping,
}: MessageBubbleProps) {
  if (role === "user") {
    return (
      <div className="flex justify-end">
        <div className="cb-user-bubble max-w-[80%] rounded-full bg-(--cb-user-bubble-bg) px-4.5 py-2.5 text-sm leading-normal text-(--cb-user-bubble-text)">
          {text}
        </div>
      </div>
    )
  }

  return (
    <p className="m-0 text-[13px] leading-[1.65] tracking-[0.01em] whitespace-pre-line text-(--cb-bot-text)">
      {text}
      {isTyping ? (
        <span
          className="cb-bot-cursor ml-1.5 inline-block h-[1.05em] w-px shrink-0 translate-y-[0.1em] rounded-full bg-(--cb-bot-text) align-baseline"
          aria-hidden
        />
      ) : null}
    </p>
  )
}
```

### `registry/new-york/blocks/chat-bot/components/typing-indicator.tsx`

```tsx
import { Lightbulb } from "lucide-react"
import type { Phase } from "../hooks/use-chat-animation"

interface ThinkLabelProps {
  phase: Phase
  thinkSeconds: number
}

export default function ThinkLabel({ phase, thinkSeconds }: ThinkLabelProps) {
  if (phase === "botThinking") {
    return (
      <div className="flex items-center gap-1.5">
        <Lightbulb
          size={13}
          className="cb-think-pulse shrink-0 text-(--cb-think-color)"
          aria-hidden
        />
        <span className="text-[11px] text-(--cb-think-color)">
          Thinking…
        </span>
      </div>
    )
  }

  if (phase === "botTyping" || phase === "paused") {
    return (
      <div className="flex items-center gap-1.5">
        <Lightbulb
          size={13}
          className="shrink-0 text-(--cb-think-color)"
          aria-hidden
        />
        <span className="text-[11px] text-(--cb-think-color)">
          Thought for {thinkSeconds}s
        </span>
      </div>
    )
  }

  return null
}
```

### `registry/new-york/blocks/chat-bot/components/chat-input.tsx`

```tsx
import { cn } from "@/lib/utils"
import { Paperclip, ArrowUp } from "lucide-react"
import type { Phase } from "../hooks/use-chat-animation"

interface ChatInputProps {
  value: string
  phase: Phase
  settled: boolean
}

export default function ChatInput({ value, phase, settled }: ChatInputProps) {
  const isSending = phase === "userSending"
  const isLoading =
    isSending ||
    phase === "botThinking" ||
    phase === "botTyping" ||
    (phase === "paused" && !settled)
  const showCursor = phase === "userTyping"

  return (
    <div
      className={cn(
        "flex items-center gap-2.5 rounded-full border border-(--cb-input-border) bg-(--cb-input-bg) py-2.5 pr-2.5 pl-4 transition-[border-color,opacity] duration-150",
        isSending && "opacity-50"
      )}
    >
      <Paperclip
        size={16}
        className="shrink-0 text-(--cb-input-icon)"
        aria-hidden
      />
      <div className="flex min-w-0 flex-1 items-center">
        {value ? (
          <span className="truncate text-sm leading-normal text-(--cb-input-text)">
            {value}
          </span>
        ) : (
          <span className="text-sm leading-normal text-(--cb-input-placeholder) select-none">
            Message…
          </span>
        )}
        {showCursor && (
          <span
            className="cb-input-cursor ml-px inline-block h-3.5 w-0.5 translate-y-px rounded-0.5 bg-(--cb-input-cursor) align-text-bottom"
            aria-hidden
          />
        )}
      </div>
      <div className="flex shrink-0 items-center gap-1.5">
        <button
          type="button"
          className={cn(
            "flex size-8 shrink-0 cursor-default items-center justify-center rounded-full border-none bg-(--cb-send-bg) text-(--cb-send-text) transition-[opacity,transform] duration-150",
            !isSending && "opacity-[0.28]",
            isSending && "cb-send-btn-active opacity-100"
          )}
          aria-label="Send message"
          tabIndex={-1}
        >
          {isLoading ? (
            <span
              className="cb-spinner block size-3.5 rounded-full border-2 border-current/25 border-t-current"
              aria-hidden
            />
          ) : (
            <ArrowUp size={15} strokeWidth={2.5} />
          )}
        </button>
      </div>
    </div>
  )
}
```

### `registry/new-york/blocks/chat-bot/hooks/use-chat-animation.ts`

```ts
import { useReducer, useLayoutEffect, useMemo, useRef } from "react"

import {
  EXCHANGE,
  TYPE_CHAR_MS,
  PRE_SEND_DELAY_MS,
  SEND_FLASH_MS,
  BOT_THINK_MS,
  BOT_TYPE_CHAR_MS,
  LOOP_PAUSE_MS,
} from "../lib/config"

export type Phase =
  | "userTyping"
  | "userSending"
  | "botThinking"
  | "botTyping"
  | "paused"

type State = {
  phase: Phase
  charIdx: number
  userSent: boolean
  thinkSeconds: number
  settled: boolean
}

type Action =
  | { type: "ADVANCE_CHAR" }
  | { type: "SET_PHASE"; phase: Phase; charIdx?: number; userSent?: boolean }
  | { type: "TICK_THINK" }
  | { type: "SET_SETTLED" }
  | { type: "RESET" }

const INITIAL: State = { phase: "userTyping", charIdx: 0, userSent: false, thinkSeconds: 0, settled: false }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "ADVANCE_CHAR":
      return { ...state, charIdx: state.charIdx + 1 }
    case "SET_PHASE":
      return {
        phase: action.phase,
        charIdx: action.charIdx ?? state.charIdx,
        userSent: action.userSent ?? state.userSent,
        thinkSeconds: action.phase === "botThinking" ? 0 : state.thinkSeconds,
        settled: false,
      }
    case "TICK_THINK":
      return { ...state, thinkSeconds: state.thinkSeconds + 1 }
    case "SET_SETTLED":
      return { ...state, settled: true }
    case "RESET":
      return INITIAL
    default:
      return state
  }
}

export function useChatAnimation() {
  const [state, dispatch] = useReducer(reducer, INITIAL)
  const { phase, charIdx, userSent, thinkSeconds, settled } = state
  const scrollRef = useRef<HTMLDivElement>(null)

  const { question, answer } = EXCHANGE

  // ── Derived display values ─────────────────────────────────────────────────

  const inputText = useMemo(() => {
    if (phase === "userTyping") return question.slice(0, charIdx)
    if (phase === "userSending") return question
    return ""
  }, [phase, charIdx, question])

  const userMessage: string | null = userSent ? question : null

  const botText = useMemo((): string | null => {
    if (phase === "botTyping") return answer.slice(0, charIdx)
    if (phase === "paused") return answer
    return null
  }, [phase, charIdx, answer])

  // ── Animation driver ───────────────────────────────────────────────────────

  useLayoutEffect(() => {
    let t: ReturnType<typeof setTimeout>

    switch (phase) {
      case "userTyping":
        if (charIdx < question.length) {
          t = setTimeout(() => dispatch({ type: "ADVANCE_CHAR" }), TYPE_CHAR_MS)
        } else {
          t = setTimeout(
            () => dispatch({ type: "SET_PHASE", phase: "userSending" }),
            PRE_SEND_DELAY_MS,
          )
        }
        break

      case "userSending":
        t = setTimeout(
          () =>
            dispatch({
              type: "SET_PHASE",
              phase: "botThinking",
              charIdx: 0,
              userSent: true,
            }),
          SEND_FLASH_MS,
        )
        break

      case "botThinking":
        t = setTimeout(
          () => dispatch({ type: "SET_PHASE", phase: "botTyping", charIdx: 0 }),
          BOT_THINK_MS,
        )
        break

      case "botTyping":
        if (charIdx < answer.length) {
          t = setTimeout(() => dispatch({ type: "ADVANCE_CHAR" }), BOT_TYPE_CHAR_MS)
        } else {
          t = setTimeout(
            () => dispatch({ type: "SET_PHASE", phase: "paused" }),
            250,
          )
        }
        break

      case "paused":
        t = setTimeout(() => dispatch({ type: "RESET" }), LOOP_PAUSE_MS)
        break
    }

    return () => clearTimeout(t)
  }, [phase, charIdx, question, answer])

  // ── Think-seconds ticker ───────────────────────────────────────────────────

  useLayoutEffect(() => {
    if (phase !== "botThinking") return
    const id = setInterval(() => dispatch({ type: "TICK_THINK" }), 1000)
    return () => clearInterval(id)
  }, [phase])

  // ── Settle after chips animate in (~500ms into paused) ─────────────────────

  useLayoutEffect(() => {
    if (phase !== "paused") return
    const t = setTimeout(() => dispatch({ type: "SET_SETTLED" }), 1150)
    return () => clearTimeout(t)
  }, [phase])

  // ── Auto-scroll ────────────────────────────────────────────────────────────

  useLayoutEffect(() => {
    const el = scrollRef.current
    if (!el) return
    el.scrollTop = el.scrollHeight - el.clientHeight
  }, [userMessage, botText, phase])

  return { phase, inputText, userMessage, botText, scrollRef, thinkSeconds, settled }
}
```

### `registry/new-york/blocks/chat-bot/lib/config.ts`

```ts
/** Typing speed for the simulated user input */
export const TYPE_CHAR_MS = 55

/** Pause after the user finishes typing before "sending" */
export const PRE_SEND_DELAY_MS = 420

/** How long the "sending" flash lasts */
export const SEND_FLASH_MS = 280

/** How long the bot "thinks" before responding */
export const BOT_THINK_MS = 1100

/** Typing speed for the bot response */
export const BOT_TYPE_CHAR_MS = 18

/** How long the completed conversation sits before looping */
export const LOOP_PAUSE_MS = 3600

export const EXCHANGE = {
  question: "Why was my transfer flagged?",
  answer: "Your $4,200 transfer to a new payee triggered a routine compliance check. It's been reviewed and cleared — funds will arrive by end of day. No action needed on your end.",
}

export const SUGGESTIONS = [
  "Set this payee as trusted",
  "View my transfer history",
] as const
```

### `registry/new-york/blocks/chat-bot/styles/chat-bot.css`

```css
/* chat-bot — theme tokens, reveal grid, keyframes, caret & stagger animations */

.cb-root {
  --cb-user-bubble-bg: #18181b;
  --cb-user-bubble-text: #fafafa;
  --cb-bot-text: #18181b;
  --cb-think-color: #71717a;
  --cb-input-bg: #f4f4f5;
  --cb-input-border: #e4e4e7;
  --cb-input-text: #18181b;
  --cb-input-placeholder: #a1a1aa;
  --cb-input-cursor: #71717a;
  --cb-input-icon: #a1a1aa;
  --cb-send-bg: #18181b;
  --cb-send-text: #ffffff;
  --cb-action-color: #a1a1aa;
  --cb-action-hover-bg: #f4f4f5;
  --cb-action-hover-color: #71717a;
  --cb-chip-border: #e4e4e7;
  --cb-chip-text: #71717a;
  --cb-chip-icon: #a1a1aa;
}

.dark .cb-root {
  --cb-user-bubble-bg: #1c1c1f;
  --cb-user-bubble-text: #f4f4f5;
  --cb-bot-text: #e4e4e7;
  --cb-think-color: #71717a;
  --cb-input-bg: #0d0d0d;
  --cb-input-border: #2a2a2a;
  --cb-input-text: #e4e4e7;
  --cb-input-placeholder: #52525b;
  --cb-input-cursor: #71717a;
  --cb-input-icon: #52525b;
  --cb-send-bg: #ffffff;
  --cb-send-text: #000000;
  --cb-action-color: #52525b;
  --cb-action-hover-bg: #1c1c1f;
  --cb-action-hover-color: #a1a1aa;
  --cb-chip-border: #27272a;
  --cb-chip-text: #a1a1aa;
  --cb-chip-icon: #52525b;
}

.cb-reveal {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}

.cb-reveal--open {
  grid-template-rows: 1fr;
}

.cb-reveal-inner {
  overflow: hidden;
  display: flex;
  flex-direction: column;
  gap: 14px;
  padding-top: 0;
  transition: padding-top 0.5s cubic-bezier(0.16, 1, 0.3, 1);
}

.cb-reveal--open .cb-reveal-inner {
  padding-top: 14px;
}

.cb-user-bubble {
  animation: cb-bubble-in 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
}

.cb-think-pulse {
  animation: cb-think-pulse 2.2s ease-in-out infinite;
}

.cb-bot-cursor,
.cb-input-cursor {
  animation: cb-blink 0.9s step-start infinite;
}

.cb-action-btn {
  animation: cb-fade-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
}

.cb-action-btn:nth-child(1) {
  animation-delay: 40ms;
}
.cb-action-btn:nth-child(2) {
  animation-delay: 110ms;
}
.cb-action-btn:nth-child(3) {
  animation-delay: 180ms;
}
.cb-action-btn:nth-child(4) {
  animation-delay: 250ms;
}
.cb-action-btn:nth-child(5) {
  animation-delay: 320ms;
}

.cb-chip {
  animation: cb-chip-in 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;
}

.cb-chip:nth-child(1) {
  animation-delay: 500ms;
}
.cb-chip:nth-child(2) {
  animation-delay: 640ms;
}

.cb-spinner {
  animation: cb-spin 0.65s linear infinite;
}

.cb-send-btn-active {
  animation: cb-send-pulse 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}

@keyframes cb-bubble-in {
  from {
    opacity: 0;
    transform: scale(0.91) translateY(8px);
  }
  to {
    opacity: 1;
    transform: scale(1) translateY(0);
  }
}

@keyframes cb-chip-in {
  from {
    opacity: 0;
    transform: translateX(-10px);
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

@keyframes cb-fade-up {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes cb-blink {
  0%,
  45% {
    opacity: 1;
  }
  55%,
  100% {
    opacity: 0;
  }
}

@keyframes cb-think-pulse {
  0%,
  100% {
    opacity: 0.3;
  }
  50% {
    opacity: 1;
  }
}

@keyframes cb-send-pulse {
  0% {
    transform: scale(1);
  }
  40% {
    transform: scale(0.76);
  }
  100% {
    transform: scale(1);
  }
}

@keyframes cb-spin {
  to {
    transform: rotate(360deg);
  }
}
```
