AI Chat Interface

PreviousNext

Chat input with attachments, model selector, and accessible controls

Docs
uitripledcomponent

Preview

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

import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import {
  ArrowUpRight,
  ChevronDown,
  Clock,
  Link2,
  Mail,
  MessageSquare,
  Share2,
  Sparkles,
  Star,
  Zap,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";

type DropdownType = "share" | "quick" | "history" | "magic" | "model" | null;

type ActionOption = {
  icon: typeof Link2;
  label: string;
  action: string;
};

const shareOptions: ActionOption[] = [
  { icon: Link2, label: "Copy link", action: "copy-link" },
  { icon: Mail, label: "Email", action: "email" },
  { icon: MessageSquare, label: "Slack", action: "slack" },
];

const quickOptions: ActionOption[] = [
  { icon: Sparkles, label: "Summarize", action: "summarize" },
  { icon: Sparkles, label: "Improve", action: "improve" },
  { icon: Sparkles, label: "Translate", action: "translate" },
];

const historyOptions: ActionOption[] = [
  { icon: Clock, label: "Last hour", action: "history-1h" },
  { icon: Clock, label: "Today", action: "history-today" },
  { icon: Clock, label: "This week", action: "history-week" },
];

const magicOptions: ActionOption[] = [
  { icon: Sparkles, label: "Auto-complete", action: "magic-complete" },
  { icon: Sparkles, label: "Storyboard", action: "magic-storyboard" },
  { icon: Sparkles, label: "Rephrase", action: "magic-rephrase" },
];

const models = ["GPT 5.0", "GPT 4.5 Turbo", "GPT 4.0", "Claude 3.5 Sonnet"];

export function AIChatInterface() {
  const [inputValue, setInputValue] = useState("");
  const [isFocused, setIsFocused] = useState(false);
  const [activeDropdown, setActiveDropdown] = useState<DropdownType>(null);
  const [selectedModel, setSelectedModel] = useState(models[0]);
  const prefersReducedMotion = useReducedMotion();
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const dropdownRef = useRef<HTMLDivElement>(null);

  const shouldAnimate = !prefersReducedMotion;

  useEffect(() => {
    function handleClickOutside(event: MouseEvent) {
      if (
        dropdownRef.current &&
        !dropdownRef.current.contains(event.target as Node)
      ) {
        setActiveDropdown(null);
      }
    }

    document.addEventListener("mousedown", handleClickOutside);
    return () => document.removeEventListener("mousedown", handleClickOutside);
  }, []);

  const handleTextareaChange = (
    event: React.ChangeEvent<HTMLTextAreaElement>
  ) => {
    setInputValue(event.target.value);

    if (textareaRef.current) {
      textareaRef.current.style.height = "auto";
      textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`;
    }
  };

  const renderDropdown = (
    type: DropdownType,
    options: ActionOption[],
    align: "left" | "right" = "left"
  ) => (
    <AnimatePresence>
      {activeDropdown === type && (
        <motion.div
          key={type}
          initial={{ opacity: 0, y: -8 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -8 }}
          transition={{ duration: 0.18, ease: "easeOut" }}
          className={`absolute ${align === "right" ? "right-0" : "left-0"} mt-3 w-56 rounded-2xl border border-border/40 bg-background/85 py-2 shadow-[0_24px_60px_rgba(15,23,42,0.24)] backdrop-blur-xl`}
          role="menu"
        >
          {options.map((option) => (
            <Button
              key={option.action}
              onClick={() => {
                console.log(`Action: ${option.action}`);
                setActiveDropdown(null);
              }}
              className="flex w-full justify-start items-center gap-3 px-4 py-2 text-sm text-foreground/85 transition-all hover:bg-foreground/[0.05] hover:text-foreground"
              role="menuitem"
              type="button"
              variant="ghost"
            >
              <option.icon size={16} className="text-foreground/60" />
              <span>{option.label}</span>
            </Button>
          ))}
        </motion.div>
      )}
    </AnimatePresence>
  );

  return (
    <motion.section
      initial={shouldAnimate ? { opacity: 0, y: 20 } : { opacity: 1, y: 0 }}
      animate={{ opacity: 1, y: 0 }}
      transition={shouldAnimate ? { duration: 0.5 } : { duration: 0 }}
      className="relative w-full"
    >
      <div className="space-y-8">
        <div
          className={`relative rounded-[28px] border border-border/50 bg-background/70 px-6 py-6 backdrop-blur-xl transition w-full z-10 ${
            isFocused ? "shadow-[0_28px_80px_rgba(15,23,42,0.24)]" : ""
          }`}
        >
          <div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/20/20 via-transparent to-transparent" />
          <div className="relative space-y-5">
            <div className="flex flex-col gap-1 text-left">
              <span className="text-xs font-semibold uppercase tracking-[0.32em] text-foreground/45">
                Prompt
              </span>
              <p className="text-sm text-foreground/60">
                Share goals, context, tone, and desired output.
              </p>
            </div>
            <label htmlFor="chat-input" className="sr-only">
              Ask AI anything
            </label>
            <Textarea
              id="chat-input"
              ref={textareaRef}
              value={inputValue}
              onChange={handleTextareaChange}
              placeholder="Ask AI anything..."
              className="w-full resize-none bg-transparent text-base text-foreground/90 placeholder:text-foreground/40 focus:outline-none"
              onFocus={() => setIsFocused(true)}
              onBlur={() => setIsFocused(false)}
              rows={1}
              aria-label="Chat input"
            />

            <div
              className="flex flex-wrap items-center gap-2 border-t border-border/25 pt-3"
              ref={dropdownRef}
            >
              <div className="relative">
                <Button
                  onClick={() =>
                    setActiveDropdown(
                      activeDropdown === "share" ? null : "share"
                    )
                  }
                  className="group rounded-xl p-2 bg-background/80 transition-all hover:bg-foreground/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/40"
                  aria-label="Share options"
                  aria-expanded={activeDropdown === "share"}
                  aria-haspopup="menu"
                  type="button"
                >
                  <Share2
                    size={18}
                    className="text-foreground/50 group-hover:text-foreground"
                    strokeWidth={2}
                  />
                </Button>
                {renderDropdown("share", shareOptions)}
              </div>

              <div className="relative">
                <Button
                  onClick={() =>
                    setActiveDropdown(
                      activeDropdown === "quick" ? null : "quick"
                    )
                  }
                  className="group rounded-xl p-2 bg-background/80 transition-all hover:bg-foreground/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/40"
                  aria-label="Quick actions"
                  aria-expanded={activeDropdown === "quick"}
                  aria-haspopup="menu"
                  type="button"
                >
                  <Zap
                    size={18}
                    className="text-foreground/50 group-hover:text-foreground"
                    strokeWidth={2}
                  />
                </Button>
                {renderDropdown("quick", quickOptions)}
              </div>

              <div className="relative">
                <Button
                  onClick={() =>
                    setActiveDropdown(
                      activeDropdown === "history" ? null : "history"
                    )
                  }
                  className="group rounded-xl p-2 bg-background/80 transition-all hover:bg-foreground/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/40"
                  aria-label="History"
                  aria-expanded={activeDropdown === "history"}
                  aria-haspopup="menu"
                  type="button"
                >
                  <Clock
                    size={18}
                    className="text-foreground/50 group-hover:text-foreground"
                    strokeWidth={2}
                  />
                </Button>
                {renderDropdown("history", historyOptions)}
              </div>

              <div className="relative">
                <Button
                  onClick={() =>
                    setActiveDropdown(
                      activeDropdown === "magic" ? null : "magic"
                    )
                  }
                  className="group rounded-xl p-2 bg-background/80 transition-all hover:bg-foreground/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/40"
                  aria-label="Magic options"
                  aria-expanded={activeDropdown === "magic"}
                  aria-haspopup="menu"
                  type="button"
                >
                  <Sparkles
                    size={18}
                    className="text-foreground/50 group-hover:text-foreground"
                    strokeWidth={2}
                  />
                </Button>
                {renderDropdown("magic", magicOptions)}
              </div>

              <div className="relative ml-auto">
                <Button
                  onClick={() =>
                    setActiveDropdown(
                      activeDropdown === "model" ? null : "model"
                    )
                  }
                  className="flex items-center gap-2 rounded-xl border border-border/30 bg-background/80 px-3 py-1.5 text-sm font-medium text-foreground/80 transition hover:border-border/40 hover:bg-background/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-foreground/40"
                  aria-label="Select AI model"
                  aria-expanded={activeDropdown === "model"}
                  aria-haspopup="listbox"
                  type="button"
                >
                  <span>{selectedModel}</span>
                  <ChevronDown size={16} className="text-foreground/50" />
                </Button>

                <AnimatePresence>
                  {activeDropdown === "model" && (
                    <motion.div
                      key="model-dropdown"
                      initial={{ opacity: 0, y: -8 }}
                      animate={{ opacity: 1, y: 0 }}
                      exit={{ opacity: 0, y: -8 }}
                      transition={{ duration: 0.18, ease: "easeOut" }}
                      className="absolute right-0 mt-3 w-56 rounded-2xl border border-border/40 bg-background/85 py-2 shadow-[0_24px_60px_rgba(15,23,42,0.24)] backdrop-blur-xl z-50"
                      role="listbox"
                    >
                      {models.map((model) => (
                        <Button
                          key={model}
                          onClick={() => {
                            setSelectedModel(model);
                            setActiveDropdown(null);
                          }}
                          className={`flex w-full items-center justify-between px-4 py-2 text-sm transition-all ${
                            selectedModel === model
                              ? "bg-primary/15 font-medium text-primary hover:bg-primary/20"
                              : "text-foreground/80 hover:bg-foreground/[0.05]"
                          }`}
                          role="option"
                          aria-selected={selectedModel === model}
                          type="button"
                          variant={
                            selectedModel === model ? "default" : "ghost"
                          }
                        >
                          {model}
                          {selectedModel === model && (
                            <Star size={14} className="text-primary/80" />
                          )}
                        </Button>
                      ))}
                    </motion.div>
                  )}
                </AnimatePresence>
              </div>

              <Button
                type="button"
                size="lg"
                className="inline-flex items-center gap-2 rounded-full px-5 text-xs uppercase tracking-[0.28em]"
                onClick={() => console.log("Action: send-message")}
              >
                Send
                <ArrowUpRight className="h-4 w-4" />
              </Button>
            </div>
          </div>
        </div>
      </div>
    </motion.section>
  );
}

Installation

npx shadcn@latest add @uitripled/ai-chat-interface

Usage

import { AiChatInterface } from "@/components/ai-chat-interface"
<AiChatInterface />