# Simple Chatbot

> Prompt card with rotating SaaS-style placeholders, light/dark via .dark ancestor, shadcn Button toolbar (add + voice).

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

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

## Install

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

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

## Dependencies

- Registry items: `button`

## Files

### `registry/new-york/blocks/simple-chatbot/page.tsx`

```tsx
import SimpleChatbot from "./components/simple-chatbot"

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

### `registry/new-york/blocks/simple-chatbot/components/simple-chatbot.tsx`

```tsx
"use client"

import { cn } from "@/lib/utils"

import PromptInput from "./prompt-input"
import { PLACEHOLDER_PROMPTS } from "../lib/config"
import { usePlaceholderLoop } from "../hooks/use-placeholder-loop"
import "../styles/simple-chatbot.css"

type SimpleChatbotProps = {
  className?: string
  prompts?: readonly string[]
}

export default function SimpleChatbot({
  className,
  prompts = PLACEHOLDER_PROMPTS,
}: SimpleChatbotProps) {
  const { displayText, showCursor, caretSolid } = usePlaceholderLoop(prompts)

  return (
    <div
      className={cn(
        "scb-root scb-page mx-auto flex w-full max-w-3xl min-w-0 justify-center px-3.5 py-[18px]",
        className
      )}
    >
      <div className="w-full max-w-[720px]">
        <PromptInput
          displayText={displayText}
          showCursor={showCursor}
          caretSolid={caretSolid}
        />
      </div>
    </div>
  )
}
```

### `registry/new-york/blocks/simple-chatbot/components/prompt-input.tsx`

```tsx
import { Mic, Plus } from "lucide-react"

import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

interface PromptInputProps {
  displayText: string
  showCursor: boolean
  caretSolid: boolean
}

const iconProps = { strokeWidth: 1.5 } as const

export default function PromptInput({
  displayText,
  showCursor,
  caretSolid,
}: PromptInputProps) {
  return (
    <div className="overflow-hidden rounded-xl border border-border bg-muted shadow-[0_0_0_1px_rgba(0,0,0,0.06)] dark:bg-background dark:shadow-[0_0_0_1px_rgba(255,255,255,0.03)]">
      <div className="min-h-[76px] px-3.5 pt-3.5 pb-2">
        <div
          className="scb-input flex min-h-10 flex-wrap items-start text-sm leading-normal"
          aria-hidden={!displayText && !showCursor}
        >
          {displayText ? (
            <span className="scb-text text-foreground blur-[0.22px] select-none">
              {displayText}
            </span>
          ) : null}
          {showCursor ? (
            <span
              className={cn(
                "scb-caret ml-0.5 inline-block shrink-0 translate-y-[0.06em] align-text-top text-[1.22em] leading-none font-medium text-foreground",
                caretSolid && "scb-caret--solid"
              )}
              aria-hidden
            >
              |
            </span>
          ) : null}
        </div>
      </div>

      <div className="flex items-center justify-between gap-2 px-2.5 pb-2.5 pl-3">
        <div className="flex min-w-0 flex-wrap items-center gap-1">
          <Button
            type="button"
            variant="ghost"
            size="icon-sm"
            className="h-[30px] w-[30px] min-w-[30px] rounded-lg text-muted-foreground hover:bg-foreground/6 hover:text-foreground"
            aria-label="Add context"
          >
            <Plus size={17} {...iconProps} />
          </Button>
        </div>

        <div className="flex min-w-0 items-center gap-1">
          <Button
            type="button"
            variant="ghost"
            className="h-[34px] w-[34px] min-w-[34px] shrink-0 rounded-[10px] bg-foreground p-0 text-background hover:bg-foreground hover:opacity-95"
            aria-label="Voice input"
          >
            <Mic size={17} className="text-background" />
          </Button>
        </div>
      </div>
    </div>
  )
}
```

### `registry/new-york/blocks/simple-chatbot/hooks/use-placeholder-loop.ts`

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

import {
  BETWEEN_MS,
  DELETE_CHAR_MS_FAST,
  DELETE_CHAR_MS_SLOW,
  HOLD_MS,
  PLACEHOLDER_PROMPTS,
  TYPE_CHAR_MS_FAST,
  TYPE_CHAR_MS_SLOW,
} from "../lib/config"

function typeDelayMs(charIdx: number, len: number): number {
  if (len <= 0) return TYPE_CHAR_MS_SLOW
  const t = charIdx / len
  // Gentle ease — smoother tempo than a steep power curve
  const ease = Math.pow(1 - t, 0.28)
  return Math.round(
    TYPE_CHAR_MS_FAST + (TYPE_CHAR_MS_SLOW - TYPE_CHAR_MS_FAST) * ease
  )
}

function deleteDelayMs(charIdx: number, len: number): number {
  if (len <= 0) return DELETE_CHAR_MS_SLOW
  const t = charIdx / len
  const ease = Math.pow(t, 0.38)
  return Math.round(
    DELETE_CHAR_MS_FAST + (DELETE_CHAR_MS_SLOW - DELETE_CHAR_MS_FAST) * ease
  )
}

type Phase = "typingIn" | "hold" | "typingOut" | "between"

type State = {
  phase: Phase
  promptIdx: number
  charIdx: number
}

type Action =
  | { type: "ADVANCE_CHAR" }
  | { type: "SET_PHASE"; phase: Phase; charIdx?: number; promptIdx?: number }
  | { type: "REWIND_CHAR" }

const INITIAL: State = { phase: "typingIn", promptIdx: 0, charIdx: 0 }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "ADVANCE_CHAR":
      return { ...state, charIdx: state.charIdx + 1 }
    case "REWIND_CHAR":
      return { ...state, charIdx: Math.max(0, state.charIdx - 1) }
    case "SET_PHASE":
      return {
        phase: action.phase,
        charIdx: action.charIdx ?? state.charIdx,
        promptIdx: action.promptIdx ?? state.promptIdx,
      }
    default:
      return state
  }
}

export function usePlaceholderLoop(prompts: readonly string[] = PLACEHOLDER_PROMPTS) {
  const [state, dispatch] = useReducer(reducer, INITIAL)
  const { phase, promptIdx, charIdx } = state

  const text = prompts[promptIdx] ?? ""
  const len = text.length

  const displayText = text.slice(0, charIdx)
  const showCursor =
    phase === "typingIn" || phase === "typingOut" || phase === "between" || phase === "hold"

  const caretSolid = phase === "typingIn" || phase === "typingOut"

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

    switch (phase) {
      case "typingIn":
        if (charIdx < len) {
          t = setTimeout(() => dispatch({ type: "ADVANCE_CHAR" }), typeDelayMs(charIdx, len))
        } else {
          dispatch({ type: "SET_PHASE", phase: "hold" })
        }
        break

      case "hold":
        t = setTimeout(() => dispatch({ type: "SET_PHASE", phase: "typingOut" }), HOLD_MS)
        break

      case "typingOut":
        if (charIdx > 0) {
          t = setTimeout(
            () => dispatch({ type: "REWIND_CHAR" }),
            deleteDelayMs(charIdx, len)
          )
        } else {
          dispatch({ type: "SET_PHASE", phase: "between", charIdx: 0 })
        }
        break

      case "between":
        t = setTimeout(() => {
          const next = (promptIdx + 1) % prompts.length
          dispatch({
            type: "SET_PHASE",
            phase: "typingIn",
            charIdx: 0,
            promptIdx: next,
          })
        }, BETWEEN_MS)
        break
    }

    return () => clearTimeout(t)
  }, [phase, charIdx, len, promptIdx, prompts.length])

  return { displayText, showCursor, caretSolid }
}
```

### `registry/new-york/blocks/simple-chatbot/lib/config.ts`

```ts
/** Type speed eased between these bounds (ms); narrow span + gentle curve = snappy, even cadence */
export const TYPE_CHAR_MS_SLOW = 40
export const TYPE_CHAR_MS_FAST = 18

/** Delete eased between these bounds (ms); quick bulk with a soft lead-in */
export const DELETE_CHAR_MS_SLOW = 30
export const DELETE_CHAR_MS_FAST = 11

/** How long the full placeholder stays visible before deleting */
export const HOLD_MS = 1500

/** Brief pause after clearing before the next phrase */
export const BETWEEN_MS = 280

export const PLACEHOLDER_PROMPTS = [
  "Explain the concept of quantum entanglement.",
  "Why does time slow down near a black hole?",
  "What is the Fermi Paradox and why does it matter?",
  "How did the universe begin before the Big Bang?",
  "What would happen if you fell into a black hole?",
  "Is mathematics discovered or invented by humans?",
] as const
```

### `registry/new-york/blocks/simple-chatbot/styles/simple-chatbot.css`

```css
/* simple-chatbot — caret blink */

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

.scb-caret {
  animation: scb-caret-blink 0.92s step-start infinite;
}

.scb-caret--solid {
  animation: none;
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .scb-caret {
    animation: none;
  }
}
```
