Chat App

PreviousNext

Fully functional chat interface with animated messages

Docs
uitripledcomponent

Preview

Loading preview…
components/components/chat/chat-app.tsx
"use client";

import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { AnimatePresence, motion } from "framer-motion";
import { Bot, Loader2, Send, User } from "lucide-react";
import { useEffect, useRef, useState } from "react";

type Message = {
  id: number;
  text: string;
  sender: "user" | "bot";
  timestamp: Date;
};

const initialMessages: Message[] = [
  {
    id: 1,
    text: "Hello! How can I help you today?",
    sender: "bot",
    timestamp: new Date(Date.now() - 60000),
  },
  {
    id: 2,
    text: "Hi there! I was wondering about your features.",
    sender: "user",
    timestamp: new Date(Date.now() - 30000),
  },
  {
    id: 3,
    text: "Of course! What would you like to know?",
    sender: "bot",
    timestamp: new Date(Date.now() - 15000),
  },
];

export function ChatApp() {
  const [messages, setMessages] = useState<Message[]>(initialMessages);
  const [inputValue, setInputValue] = useState("");
  const [isTyping, setIsTyping] = useState(false);
  const scrollAreaRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    // Auto-scroll to bottom when new messages arrive
    if (scrollAreaRef.current) {
      const scrollContainer = scrollAreaRef.current.querySelector(
        "[data-radix-scroll-area-viewport]"
      );
      if (scrollContainer) {
        scrollContainer.scrollTop = scrollContainer.scrollHeight;
      }
    }
  }, [messages, isTyping]);

  const formatTime = (date: Date): string => {
    const hours = date.getHours();
    const minutes = date.getMinutes();
    const ampm = hours >= 12 ? "PM" : "AM";
    const displayHours = hours % 12 || 12;
    const displayMinutes = minutes.toString().padStart(2, "0");
    return `${displayHours}:${displayMinutes} ${ampm}`;
  };

  const handleSend = () => {
    const trimmed = inputValue.trim();
    if (!trimmed) return;

    const userMessage: Message = {
      id: Date.now(),
      text: trimmed,
      sender: "user",
      timestamp: new Date(),
    };

    setMessages((prev) => [...prev, userMessage]);
    setInputValue("");
    setIsTyping(true);

    // Simulate bot response with typing indicator
    setTimeout(() => {
      const botMessage: Message = {
        id: Date.now() + 1,
        text: "Thanks for your message! This is an automated response. I can help you with various tasks and answer your questions.",
        sender: "bot",
        timestamp: new Date(),
      };
      setMessages((prev) => [...prev, botMessage]);
      setIsTyping(false);
      inputRef.current?.focus();
    }, 1500);
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  return (
    <div className="">
      <Card className="flex h-[700px] w-full md:w-[600px] mx-auto flex-col overflow-hidden shadow-2xl">
        {/* Header */}
        <motion.div
          initial={{ opacity: 0, y: -20 }}
          animate={{ opacity: 1, y: 0 }}
          className="border-b bg-gradient-to-r from-primary/10 via-accent/10 to-primary/10 px-6 py-4"
        >
          <div className="flex items-center gap-4">
            <motion.div
              className="relative flex h-12 w-12 items-center justify-center rounded-full bg-primary shadow-lg"
              whileHover={{ scale: 1.05 }}
              whileTap={{ scale: 0.95 }}
            >
              <Bot
                className="h-6 w-6 text-primary-foreground"
                aria-hidden="true"
              />
              <motion.div
                className="absolute -right-1 -top-1 h-3 w-3 rounded-full bg-green-500 ring-2 ring-background"
                initial={{ scale: 0 }}
                animate={{ scale: 1 }}
                transition={{ delay: 0.5 }}
              />
            </motion.div>
            <div>
              <h1 className="text-lg font-semibold">AI Assistant</h1>
              <p className="text-sm text-muted-foreground">
                Always here to help
              </p>
            </div>
          </div>
        </motion.div>

        {/* Messages */}
        <ScrollArea ref={scrollAreaRef} className="flex-1 px-6">
          <div
            className="space-y-6 py-6"
            role="log"
            aria-live="polite"
            aria-label="Chat messages"
          >
            <AnimatePresence mode="popLayout">
              {messages.map((message) => (
                <motion.div
                  key={message.id}
                  layout
                  initial={{ opacity: 0, y: 20, scale: 0.95 }}
                  animate={{ opacity: 1, y: 0, scale: 1 }}
                  exit={{ opacity: 0, scale: 0.95 }}
                  transition={{
                    duration: 0.3,
                    ease: [0.4, 0, 0.2, 1],
                  }}
                  className={`flex gap-3 ${
                    message.sender === "user" ? "flex-row-reverse" : "flex-row"
                  }`}
                >
                  <motion.div
                    initial={{ scale: 0 }}
                    animate={{ scale: 1 }}
                    transition={{
                      delay: 0.1,
                      type: "spring",
                      stiffness: 260,
                      damping: 20,
                    }}
                    className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full shadow-md ${
                      message.sender === "user" ? "bg-primary" : "bg-accent"
                    }`}
                    aria-hidden="true"
                  >
                    {message.sender === "user" ? (
                      <User className="h-5 w-5 text-primary-foreground" />
                    ) : (
                      <Bot className="h-5 w-5 text-accent-foreground" />
                    )}
                  </motion.div>
                  <div
                    className={`flex max-w-[75%] flex-col ${
                      message.sender === "user" ? "items-end" : "items-start"
                    }`}
                  >
                    <motion.div
                      initial={{ scale: 0.9, opacity: 0 }}
                      animate={{ scale: 1, opacity: 1 }}
                      transition={{ delay: 0.15 }}
                      className={`rounded-2xl px-4 py-3 shadow-sm ${
                        message.sender === "user"
                          ? "bg-primary text-primary-foreground"
                          : "bg-accent text-accent-foreground"
                      }`}
                    >
                      <p className="text-sm leading-relaxed">{message.text}</p>
                    </motion.div>
                    <time
                      className="mt-1.5 px-1 text-xs text-muted-foreground"
                      dateTime={message.timestamp.toISOString()}
                    >
                      {formatTime(message.timestamp)}
                    </time>
                  </div>
                </motion.div>
              ))}
            </AnimatePresence>

            {/* Typing Indicator */}
            <AnimatePresence>
              {isTyping && (
                <motion.div
                  initial={{ opacity: 0, y: 10 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: -10 }}
                  className="flex gap-3"
                  aria-live="polite"
                  aria-label="AI is typing"
                >
                  <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-accent shadow-md">
                    <Bot
                      className="h-5 w-5 text-accent-foreground"
                      aria-hidden="true"
                    />
                  </div>
                  <div className="flex items-center gap-1 rounded-2xl bg-accent px-4 py-3 shadow-sm">
                    <motion.div
                      className="h-2 w-2 rounded-full bg-accent-foreground/60"
                      animate={{ scale: [1, 1.2, 1] }}
                      transition={{ duration: 0.6, repeat: Infinity, delay: 0 }}
                    />
                    <motion.div
                      className="h-2 w-2 rounded-full bg-accent-foreground/60"
                      animate={{ scale: [1, 1.2, 1] }}
                      transition={{
                        duration: 0.6,
                        repeat: Infinity,
                        delay: 0.2,
                      }}
                    />
                    <motion.div
                      className="h-2 w-2 rounded-full bg-accent-foreground/60"
                      animate={{ scale: [1, 1.2, 1] }}
                      transition={{
                        duration: 0.6,
                        repeat: Infinity,
                        delay: 0.4,
                      }}
                    />
                  </div>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        </ScrollArea>

        {/* Input */}
        <motion.div
          initial={{ opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ delay: 0.2 }}
          className="border-t bg-gradient-to-r from-background via-accent/5 to-background p-6"
        >
          <div className="flex gap-3" role="group" aria-label="Message input">
            <Input
              ref={inputRef}
              value={inputValue}
              onChange={(e) => setInputValue(e.target.value)}
              onKeyDown={handleKeyDown}
              placeholder="Type your message..."
              className="flex-1 rounded-full border-2 px-4 focus-visible:ring-2 focus-visible:ring-primary"
              aria-label="Message input"
              disabled={isTyping}
            />
            <Button
              onClick={handleSend}
              size="icon"
              disabled={!inputValue.trim() || isTyping}
              aria-label="Send message"
              type="button"
              className="h-11 w-11 rounded-full shadow-md transition-all hover:scale-105 hover:shadow-lg active:scale-95 disabled:opacity-50"
            >
              {isTyping ? (
                <Loader2 className="h-5 w-5 animate-spin" aria-hidden="true" />
              ) : (
                <Send className="h-5 w-5" aria-hidden="true" />
              )}
            </Button>
          </div>
        </motion.div>
      </Card>
    </div>
  );
}

Installation

npx shadcn@latest add @uitripled/chat-app

Usage

import { ChatApp } from "@/components/chat-app"
<ChatApp />