Edu Calvo

AI Conversation

AI chats

About

Auto-scrolling chat container that follows streaming output, stops following the moment the user scrolls up, and offers a jump-to-latest control. Respects prefers-reduced-motion.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds AI Conversation to your project and fits it to your theme.

Code

How AI Conversation is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import { useEffect, useState } from "react";
import Component from "@/components/ui/ai-conversation";

type Turn = {
  from: "assistant" | "user";
  kind?: "code" | "text" | "tool";
  text: string;
};

const TURNS: Turn[] = [
  { from: "user", text: "Why does my dialog feel sluggish?" },
  {
    from: "assistant",
    text: "It animates for 600ms on an ease-in-out curve, so the first third of the motion barely moves. The eye reads that as lag before it reads it as animation.",
  },
  { from: "user", text: "What should I use instead?" },
  {
    from: "assistant",
    kind: "tool",
    text: "read packages/ui/dialog.tsx · 84 lines",
  },
  {
    from: "assistant",
    kind: "code",
    text: 'transition={{ type: "spring", duration: 0.25, bounce: 0.1 }}',
  },
  {
    from: "assistant",
    text: "A 0.25s spring with a low bounce. It reads as instant while still animating, and it stays interruptible if the user closes early.",
  },
  { from: "user", text: "Does that hold up with reduced motion on?" },
  {
    from: "assistant",
    text: "The component checks prefers-reduced-motion and drops to a zero-duration opacity swap, so the dialog still appears — just without the travel.",
  },
];

const START_AT = 4;
const STEP_MS = 1600;

export default function DemoOne() {
  const [count, setCount] = useState(START_AT);

  useEffect(() => {
    const id = setInterval(
      () =>
        setCount((current) =>
          current >= TURNS.length ? START_AT : current + 1
        ),
      STEP_MS
    );
    return () => clearInterval(id);
  }, []);

  return (
    <div className="flex min-h-[700px] w-full items-center justify-center bg-background p-8">
      <div className="flex h-[600px] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border bg-card shadow-sm">
        <header className="flex items-center gap-3 border-b px-5 py-4">
          <span className="flex size-8 items-center justify-center rounded-full bg-foreground font-semibold text-[11px] text-background">
            AI
          </span>
          <div className="min-w-0 flex-1">
            <p className="truncate font-semibold text-foreground text-sm">
              Motion review
            </p>
            <p className="text-muted-foreground text-xs">
              {count === TURNS.length ? "idle" : "streaming…"} ·{" "}
              {TURNS.length} messages
            </p>
          </div>
          <span className="rounded-full border px-2.5 py-1 text-muted-foreground text-xs">
            auto-scroll
          </span>
        </header>

        <Component className="flex-1" contentKey={count}>
          <div className="space-y-4 p-5">
            {TURNS.slice(0, count).map((turn) => {
              if (turn.kind === "tool") {
                return (
                  <div
                    className="flex items-center gap-2 rounded-xl border border-dashed px-3 py-2 text-muted-foreground text-xs"
                    key={turn.text}
                  >
                    <span className="size-1.5 rounded-full bg-muted-foreground" />
                    {turn.text}
                  </div>
                );
              }

              if (turn.kind === "code") {
                return (
                  <pre
                    className="overflow-x-auto rounded-xl bg-muted px-4 py-3 font-mono text-foreground text-xs leading-relaxed"
                    key={turn.text}
                  >
                    {turn.text}
                  </pre>
                );
              }

              return (
                <div
                  className={
                    turn.from === "user"
                      ? "ml-auto max-w-[78%] rounded-2xl rounded-br-md bg-foreground px-4 py-2.5 text-background text-sm leading-relaxed"
                      : "max-w-[82%] rounded-2xl rounded-bl-md border px-4 py-2.5 text-foreground text-sm leading-relaxed"
                  }
                  key={turn.text}
                >
                  {turn.text}
                </div>
              );
            })}
          </div>
        </Component>

        <footer className="flex items-center gap-3 border-t px-5 py-4">
          <p className="flex-1 rounded-full border px-4 py-2.5 text-muted-foreground text-sm">
            Reply to the assistant…
          </p>
          <span className="flex size-9 items-center justify-center rounded-full bg-foreground text-background text-sm">
            ↑
          </span>
        </footer>
      </div>
    </div>
  );
}

Dependencies

More by Edu Calvo

View all
  • Scrollable Card Stack

    Scrollable Card Stack

    235
  • AI Task List

    AI Task List

    160
  • Siri Orb

    Siri Orb

    160
  • Cursor Follow

    Cursor Follow

    154
  • Pixel Flow Field

    Pixel Flow Field

    143
  • Agent Avatar

    Agent Avatar

    130
  • Gravity Stars

    Gravity Stars

    112
  • Coverflow Carousel

    Coverflow Carousel

    95

More in AI chats

View all
  • AI Actions

    AI Actions

    319
  • Color Picker

    Color Picker

    82
  • Agent Chat

    Agent Chat

    17
  • Prompt with Model Picker

    Prompt with Model Picker

    2
  • System Message

    System Message

    54
  • Message Thread

    Message Thread

    36
  • Suggestions

    Suggestions

    28
  • Message With Header And Delivery Status

    Message With Header And Delivery Status

    38