Chat Input Box
A comprehensive AI chat input component with multi-provider model selection, file upload, drag-and-drop support, and customizable tools for modern AI chat applications.
Preview
Generate with AI
Want to create this component using AI? Copy the prompt below and paste it into any LLM (ChatGPT, Claude, etc.):
Installation
npx shadcn@latest add https://uiregistry.cappychat.com/registry/chat-input-box.jsonInstall dependencies
npm i framer-motion lucide-react clsx tailwind-merge react-iconsAdd util file
lib/utils.ts
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}Copy the source code
components/ui/chat-input-box.tsx
src/components/AIapplicationsComponents/chat-input-box.tsx
"use client";
import React, { useState, useRef, useCallback, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import Image from "next/image";
import {
Send,
Upload,
ChevronDown,
Paperclip,
X,
FileText,
Image as ImageIcon,
File,
Loader2,
Search,
Check,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { SiClaude } from "react-icons/si";
import { TbBrandOpenai } from "react-icons/tb";
import { RiGeminiFill } from "react-icons/ri";
import { FaTools } from "react-icons/fa";
// Constants
const ICON_SIZE = "h-4 w-4";
const DROPDOWN_ANIMATION = {
initial: { opacity: 0, y: -10 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -10 },
};
// Custom SVG Icons
const QwenIcon = ({ className }: { className?: string }) => (
<svg
fill="currentColor"
fillRule="evenodd"
height="1em"
style={{ flex: "none", lineHeight: 1 }}
viewBox="0 0 24 24"
width="1em"
className={className}
>
<path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" />
</svg>
);
const DeepSeekIcon = ({ className }: { className?: string }) => (
<svg
viewBox="0 0 24 24"
style={{ flex: "none", lineHeight: 1 }}
className={className}
>
<path
fill="currentColor"
d="M23.748 4.482c-.254-.124-.364.113-.512.234-.051.039-.094.09-.137.136-.372.397-.806.657-1.373.626-.829-.046-1.537.214-2.163.848-.133-.782-.575-1.248-1.247-1.548-.352-.156-.708-.311-.955-.65-.172-.241-.219-.51-.305-.774-.055-.16-.11-.323-.293-.35-.2-.031-.278.136-.356.276-.313.572-.434 1.202-.422 1.84.027 1.436.633 2.58 1.838 3.393.137.093.172.187.129.323-.082.28-.18.552-.266.833-.055.179-.137.217-.329.14a5.526 5.526 0 0 1-1.736-1.18c-.857-.828-1.631-1.742-2.597-2.458a11.365 11.365 0 0 0-.689-.471c-.985-.957.13-1.743.388-1.836.27-.098.093-.432-.779-.428-.872.004-1.67.295-2.687.684a3.055 3.055 0 0 1-.465.137 9.597 9.597 0 0 0-2.883-.102c-1.885.21-3.39 1.102-4.497 2.623C.082 8.606-.231 10.684.152 12.85c.403 2.284 1.569 4.175 3.36 5.653 1.858 1.533 3.997 2.284 6.438 2.14 1.482-.085 3.133-.284 4.994-1.86.47.234.962.327 1.78.397.63.059 1.236-.03 1.705-.128.735-.156.684-.837.419-.961-2.155-1.004-1.682-.595-2.113-.926 1.096-1.296 2.746-2.642 3.392-7.003.05-.347.007-.565 0-.845-.004-.17.035-.237.23-.256a4.173 4.173 0 0 0 1.545-.475c1.396-.763 1.96-2.015 2.093-3.517.02-.23-.004-.467-.247-.588zM11.581 18c-2.089-1.642-3.102-2.183-3.52-2.16-.392.024-.321.471-.235.763.09.288.207.486.371.739.114.167.192.416-.113.603-.673.416-1.842-.14-1.897-.167-1.361-.802-2.5-1.86-3.301-3.307-.774-1.393-1.224-2.887-1.298-4.482-.02-.386.093-.522.477-.592a4.696 4.696 0 0 1 1.529-.039c2.132.312 3.946 1.265 5.468 2.774.868.86 1.525 1.887 2.202 2.891.72 1.066 1.494 2.082 2.48 2.914.348.292.625.514.891.677-.802.09-2.14.11-3.054-.614zm1-6.44a.306.306 0 0 1 .415-.287.302.302 0 0 1 .2.288.306.306 0 0 1-.31.307.303.303 0 0 1-.304-.308zm3.11 1.596c-.2.081-.399.151-.59.16a1.245 1.245 0 0 1-.798-.254c-.274-.23-.47-.358-.552-.758a1.73 1.73 0 0 1 .016-.588c.07-.327-.008-.537-.239-.727-.187-.156-.426-.199-.688-.199a.559.559 0 0 1-.254-.078.253.253 0 0 1-.114-.358c.028-.054.16-.186.192-.21.356-.202.767-.136 1.146.016.352.144.618.408 1.001.782.391.451.462.576.685.914.176.265.336.537.445.848.067.195-.019.354-.25.452z"
/>
</svg>
);
// AI Models data
const AI_MODELS = {
claude: [
{
id: "claude-sonnet-4",
name: "Claude Sonnet 4",
icon: <SiClaude className="h-4 w-4" />,
},
{
id: "claude-sonnet-4.5",
name: "Claude Sonnet 4.5",
icon: <SiClaude className="h-4 w-4" />,
},
{
id: "claude-haiku-3.5",
name: "Claude Haiku 3.5",
icon: <SiClaude className="h-4 w-4" />,
},
],
google: [
{
id: "gemini-2.5-pro",
name: "Gemini 2.5 Pro",
icon: <RiGeminiFill className="h-4 w-4" />,
},
{
id: "gemini-2.5-flash",
name: "Gemini 2.5 Flash",
icon: <RiGeminiFill className="h-4 w-4" />,
},
],
openai: [
{
id: "openai-5",
name: "OpenAI 5",
icon: <TbBrandOpenai className="h-4 w-4" />,
},
{
id: "openai-5-nano",
name: "OpenAI 5 Nano",
icon: <TbBrandOpenai className="h-4 w-4" />,
},
{
id: "openai-5-mini",
name: "OpenAI 5 Mini",
icon: <TbBrandOpenai className="h-4 w-4" />,
},
{
id: "openai-o4-mini",
name: "OpenAI o4 Mini",
icon: <TbBrandOpenai className="h-4 w-4" />,
},
],
qwen: [
{
id: "qwen-3-max",
name: "Qwen3 Max",
icon: <QwenIcon className="h-4 w-4" />,
},
{
id: "qwen-3-235b-a22b",
name: "Qwen3 235B A22B",
icon: <QwenIcon className="h-4 w-4" />,
},
{
id: "qwen-3-30b-a3b-thinking-2507",
name: "Qwen3 30B A3B Thinking 2507",
icon: <QwenIcon className="h-4 w-4" />,
},
],
deepseek: [
{
id: "deepseek-r1-fast",
name: "DeepSeek R1 Fast",
icon: <DeepSeekIcon className="h-4 w-4" />,
},
{
id: "deepseek-v3.1",
name: "DeepSeek V3.1",
icon: <DeepSeekIcon className="h-4 w-4" />,
},
],
};
// Style options
const STYLE_OPTIONS = [
{
id: "technical",
name: "Technical",
description: "Detailed technical explanations",
},
{
id: "study",
name: "Study",
description: "Educational and learning focused",
},
{
id: "classic",
name: "Classic",
description: "Traditional and formal tone",
},
{
id: "professional",
name: "Professional",
description: "Business and work appropriate",
},
{ id: "formal", name: "Formal", description: "Structured and official" },
];
interface UploadedFile {
id: string;
name: string;
size: number;
type: string;
url?: string;
isUploading: boolean;
progress: number;
}
interface ChatInputBoxProps {
className?: string;
}
export default function ChatInputBox({ className }: ChatInputBoxProps) {
const [message, setMessage] = useState("");
const [selectedModel, setSelectedModel] = useState("claude-sonnet-4");
const [selectedStyle, setSelectedStyle] = useState("classic");
const [showModelDropdown, setShowModelDropdown] = useState(false);
const [showToolsDropdown, setShowToolsDropdown] = useState(false);
const [showStyleDropdown, setShowStyleDropdown] = useState(false);
const [modelSearchQuery, setModelSearchQuery] = useState("");
const [webSearch, setWebSearch] = useState(false);
const [redditSearch, setRedditSearch] = useState(false);
const [createImage, setCreateImage] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const toolsDropdownRef = useRef<HTMLDivElement>(null);
// Get selected model details
const getSelectedModelDetails = useCallback(() => {
for (const category of Object.values(AI_MODELS)) {
const model = category.find((m) => m.id === selectedModel);
if (model) return model;
}
return AI_MODELS.claude[0];
}, [selectedModel]);
const selectedModelDetails = getSelectedModelDetails();
// Get selected style details
const getSelectedStyleDetails = useCallback(() => {
return (
STYLE_OPTIONS.find((style) => style.id === selectedStyle) ||
STYLE_OPTIONS[2]
); // Default to classic
}, [selectedStyle]);
const selectedStyleDetails = getSelectedStyleDetails();
// Filter models based on search query
const getFilteredModels = useCallback(() => {
if (!modelSearchQuery.trim()) return AI_MODELS;
const filtered: Partial<typeof AI_MODELS> = {};
Object.entries(AI_MODELS).forEach(([category, models]) => {
const filteredModels = models.filter(
(model) =>
model.name.toLowerCase().includes(modelSearchQuery.toLowerCase()) ||
category.toLowerCase().includes(modelSearchQuery.toLowerCase())
);
if (filteredModels.length > 0) {
filtered[category as keyof typeof AI_MODELS] = filteredModels;
}
});
return filtered;
}, [modelSearchQuery]);
const filteredModels = getFilteredModels();
// Utility functions
const getFileIcon = useCallback((type: string) => {
if (type.startsWith("image/"))
return <ImageIcon className={ICON_SIZE} aria-label="Image file" />;
if (type === "application/pdf" || type.includes("document"))
return <FileText className={ICON_SIZE} aria-label="Document file" />;
return <File className={ICON_SIZE} aria-label="File" />;
}, []);
// File processing helpers
const createUploadedFile = useCallback(
(file: File, customName?: string): UploadedFile => ({
id: Math.random().toString(36).substring(2, 11),
name: customName || file.name,
size: file.size,
type: file.type,
isUploading: true,
progress: 0,
}),
[]
);
// Simulate file upload with progress
const simulateUpload = useCallback(
(file: UploadedFile, actualFile?: File) => {
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 30;
if (progress >= 100) {
progress = 100;
clearInterval(interval);
setUploadedFiles((prev) =>
prev.map((f) =>
f.id === file.id
? {
...f,
isUploading: false,
progress: 100,
url:
actualFile && file.type.startsWith("image/")
? URL.createObjectURL(actualFile)
: undefined,
}
: f
)
);
} else {
setUploadedFiles((prev) =>
prev.map((f) => (f.id === file.id ? { ...f, progress } : f))
);
}
}, 200);
},
[]
);
const processFileUpload = useCallback(
(file: File, customName?: string) => {
const uploadedFile = createUploadedFile(file, customName);
setUploadedFiles((prev) => [...prev, uploadedFile]);
simulateUpload(uploadedFile, file);
},
[createUploadedFile, simulateUpload]
);
// Remove uploaded file
const removeFile = useCallback((fileId: string) => {
setUploadedFiles((prev) => {
const fileToRemove = prev.find((f) => f.id === fileId);
if (fileToRemove?.url && fileToRemove.url.startsWith("blob:")) {
URL.revokeObjectURL(fileToRemove.url);
}
return prev.filter((f) => f.id !== fileId);
});
}, []);
// Handle file upload
const handleFileUpload = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
Array.from(files).forEach((file) => processFileUpload(file));
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
},
[processFileUpload]
);
// Handle paste
const handlePaste = useCallback(
(e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
Array.from(items).forEach((item) => {
if (item.type.startsWith("image/")) {
const file = item.getAsFile();
if (file) {
processFileUpload(file, `pasted-image-${Date.now()}.png`);
}
}
});
},
[processFileUpload]
);
// Drag and drop handlers
const handleDragEvents = useCallback(
(e: React.DragEvent, type: "enter" | "leave" | "over" | "drop") => {
e.preventDefault();
e.stopPropagation();
switch (type) {
case "enter":
setIsDragOver(true);
break;
case "leave":
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
break;
case "drop":
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
files.forEach((file) => processFileUpload(file));
break;
}
},
[processFileUpload]
);
// Handle send message
const handleSend = useCallback(() => {
if (message.trim()) {
console.log("Sending message:", {
message: message.trim(),
model: selectedModel,
style: selectedStyle,
webSearch,
redditSearch,
createImage,
});
setMessage("");
}
}, [
message,
selectedModel,
selectedStyle,
webSearch,
redditSearch,
createImage,
]);
// Handle Enter key
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend]
);
// Cleanup object URLs on unmount
useEffect(() => {
return () => {
uploadedFiles.forEach((file) => {
if (file.url && file.url.startsWith("blob:")) {
URL.revokeObjectURL(file.url);
}
});
};
}, [uploadedFiles]);
// Handle click outside dropdowns
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
// Close model dropdown if clicked outside
if (
showModelDropdown &&
modelDropdownRef.current &&
!modelDropdownRef.current.contains(target)
) {
setShowModelDropdown(false);
setModelSearchQuery(""); // Clear search when closing
}
// Close tools dropdown if clicked outside (but not if clicking on style dropdown)
if (
showToolsDropdown &&
toolsDropdownRef.current &&
!toolsDropdownRef.current.contains(target)
) {
setShowToolsDropdown(false);
setShowStyleDropdown(false); // Also close style dropdown when tools dropdown closes
}
// Close style dropdown if tools dropdown is closed
if (!showToolsDropdown && showStyleDropdown) {
setShowStyleDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [showModelDropdown, showToolsDropdown, showStyleDropdown]);
return (
<div className="">
{/* File Preview Section */}
<AnimatePresence>
{uploadedFiles.length > 0 && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="mb-2"
>
<div className="flex flex-wrap gap-3">
{uploadedFiles.map((file) => (
<motion.div
key={file.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="relative group"
>
{/* File Preview Card */}
<div className="relative w-20 h-20 rounded-xl overflow-hidden border border-fd-border bg-fd-background/50 flex items-center justify-center">
{file.isUploading ? (
// Loading State
<Loader2 className="h-8 w-8 animate-spin text-fd-primary" />
) : file.type.startsWith("image/") && file.url ? (
// Image Preview
<Image
src={file.url}
alt={`Preview of ${file.name}`}
width={80}
height={80}
className="w-full h-full object-cover"
/>
) : (
// File Icon
<div className="flex items-center justify-center">
{getFileIcon(file.type)}
</div>
)}
</div>
{/* Remove Button */}
<button
type="button"
onClick={() => removeFile(file.id)}
className="absolute -top-2 -right-2 p-1 rounded-full bg-fd-background border border-fd-border hover:bg-red-500 transition-colors md:opacity-0 md:group-hover:opacity-100"
>
<X className="h-3 w-3 text-fd-accent-foreground" />
</button>
{/* File Name Tooltip */}
<div className="absolute bottom-0 left-0 right-0 bg-fd-background/90 backdrop-blur-sm px-2 py-1 text-xs text-fd-foreground rounded-b-xl truncate md:opacity-0 md:group-hover:opacity-100 transition-opacity">
{file.name}
</div>
</motion.div>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<div
className={cn(
"w-full max-w-4xl mx-auto rounded-3xl border-2 transition-all duration-200 outline-2 outline-fd-accent bg-fd-card/40 backdrop-blur-sm relative",
isDragOver
? "border-fd-primary bg-fd-primary/5 scale-[1.02]"
: "border-fd-border",
className
)}
onDragEnter={(e) => handleDragEvents(e, "enter")}
onDragLeave={(e) => handleDragEvents(e, "leave")}
onDragOver={(e) => handleDragEvents(e, "over")}
onDrop={(e) => handleDragEvents(e, "drop")}
>
{/* Drag Overlay */}
{isDragOver && (
<div className="absolute inset-0 rounded-3xl bg-fd-primary/10 border-2 border-dashed border-fd-primary flex items-center justify-center z-10">
<div className="text-center">
<Upload className="h-8 w-8 text-fd-primary mx-auto mb-2" />
<p className="text-sm font-medium text-fd-primary">
Drop files here to upload
</p>
</div>
</div>
)}
{/* Main Input Row */}
<div className="flex pt-2 px-4 items-end gap-3">
<div className="flex-1 relative">
<textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="Ask anything you want !!"
className="w-full min-h-[48px] max-h-32 px-2 py-3 pr-12 rounded-2xl border-transparent text-fd-foreground placeholder:text-fd-muted-foreground resize-none focus:outline-none focus:ring-none transition-all"
rows={1}
style={{
scrollbarWidth: "none",
msOverflowStyle: "none",
}}
/>
</div>
</div>
<div className="h-[1px] bg-fd-border w-full"></div>
{/* Controls Row */}
<div className="flex items-center p-3 py-4 md:p-4 justify-between flex-wrap">
<div className="flex justify-center gap-1.5 md:gap-2">
{/* Tools Dropdown */}
<div className="relative" ref={toolsDropdownRef}>
<button
type="button"
onClick={() => setShowToolsDropdown(!showToolsDropdown)}
className="flex items-center gap-2 p-3 rounded-lg border border-fd-border bg-fd-background/50 hover:bg-fd-accent transition-colors"
>
<FaTools className={`${ICON_SIZE} text-fd-muted-foreground`} />
{/* <span className="text-sm font-medium">Tools</span> */}
{/* <ChevronDown className="h-4 w-4 text-fd-muted-foreground" /> */}
</button>
<AnimatePresence>
{showToolsDropdown && (
<motion.div
{...DROPDOWN_ANIMATION}
className="absolute top-full left-0 mt-2 w-40 md:w-64 rounded-xl border border-fd-border bg-fd-card backdrop-blur-xl shadow-xl z-[100]"
>
<div className="p-4">
{/* Style Selector */}
<div className="relative mb-4">
<button
type="button"
onClick={() =>
setShowStyleDropdown(!showStyleDropdown)
}
className="w-full flex items-center justify-between transition-colors"
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Style:</span>
<span className="text-sm text-fd-muted-foreground">
{selectedStyleDetails.name}
</span>
</div>
<ChevronDown
className={cn(
`${ICON_SIZE} text-fd-muted-foreground transition-transform duration-200`,
showStyleDropdown && "rotate-180"
)}
/>
</button>
<AnimatePresence>
{showStyleDropdown && (
<motion.div
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 10 }}
className="absolute top-0 left-full ml-2 w-40 md:w-64 rounded-xl border border-fd-border bg-fd-card backdrop-blur-xl shadow-xl z-[110]"
>
<div className="py-1 px-2 overflow-y-auto">
{STYLE_OPTIONS.map((style) => (
<button
key={style.id}
onClick={() => {
setSelectedStyle(style.id);
setShowStyleDropdown(false);
}}
className={cn(
"w-full text-left my-2 px-3 py-1.5 rounded-lg hover:bg-fd-accent transition-colors flex items-center justify-between",
selectedStyle === style.id &&
"bg-fd-accent ring-1 ring-fd-primary/20"
)}
>
<div className="flex-1">
<div className="font-medium text-sm">
{style.name}
</div>
<div className="text-xs truncate w-20 md:truncate-none md:w-auto text-fd-muted-foreground">
{style.description}
</div>
</div>
{selectedStyle === style.id && (
<Check className="h-4 w-4 text-fd-primary flex-shrink-0 ml-2" />
)}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Separator */}
<div className="border-t border-fd-border my-4"></div>
{/* Toggle Options */}
<div className="space-y-3 md:space-y-4">
<label className="flex items-center justify-between py-1">
<span className="text-xs md:text-sm font-medium">
Web Search
</span>
<button
type="button"
onClick={() => setWebSearch(!webSearch)}
className={cn(
"relative w-9 md:w-11 h-5 md:h-6 rounded-full transition-colors",
webSearch ? "bg-fd-primary" : "bg-fd-muted"
)}
>
<div
className={cn(
"absolute top-0.5 w-4 md:w-5 h-4 md:h-5 bg-fd-muted-foreground rounded-full transition-transform shadow-sm",
webSearch
? "translate-x-4 md:translate-x-5"
: "translate-x-0.5"
)}
/>
</button>
</label>
<label className="flex items-center justify-between py-1">
<span className="text-xs md:text-sm font-medium">
Reddit Search
</span>
<button
type="button"
onClick={() => setRedditSearch(!redditSearch)}
className={cn(
"relative w-9 md:w-11 h-5 md:h-6 rounded-full transition-colors",
redditSearch ? "bg-fd-primary" : "bg-fd-muted"
)}
>
<div
className={cn(
"absolute top-0.5 w-4 md:w-5 h-4 md:h-5 bg-fd-muted-foreground rounded-full transition-transform shadow-sm",
redditSearch
? "translate-x-4 md:translate-x-5"
: "translate-x-0.5"
)}
/>
</button>
</label>
<label className="flex items-center justify-between py-1">
<span className="text-xs md:text-sm font-medium">
Create Image
</span>
<button
type="button"
onClick={() => setCreateImage(!createImage)}
className={cn(
"relative w-9 md:w-11 h-5 md:h-6 rounded-full transition-colors",
createImage ? "bg-fd-primary" : "bg-fd-muted"
)}
>
<div
className={cn(
"absolute top-0.5 w-4 md:w-5 h-4 md:h-5 bg-fd-muted-foreground rounded-full transition-transform shadow-sm",
createImage
? "translate-x-4 md:translate-x-5"
: "translate-x-0.5"
)}
/>
</button>
</label>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Upload Button */}
<button
type="button"
onClick={handleFileUpload}
className="p-2 rounded-lg border border-fd-border bg-fd-background/50 hover:bg-fd-accent transition-colors"
>
<Paperclip className="h-5 w-5 text-fd-muted-foreground" />
</button>
{/* Model Selector */}
<div className="relative" ref={modelDropdownRef}>
<button
type="button"
onClick={() => setShowModelDropdown(!showModelDropdown)}
className="flex items-center gap-2 px-3 md:px-4 py-2.5 rounded-lg border border-fd-border bg-fd-background/50 hover:bg-fd-accent transition-colors"
>
{selectedModelDetails.icon}
<span className="text-sm font-medium w-16 md:w-auto truncate">
{selectedModelDetails.name}
</span>
<ChevronDown
className={cn(
`${ICON_SIZE} text-fd-muted-foreground transition-transform duration-200`,
showModelDropdown && "rotate-180"
)}
/>
</button>
<AnimatePresence>
{showModelDropdown && (
<motion.div
{...DROPDOWN_ANIMATION}
className="absolute top-full left-0 mt-2 w-52 md:w-72 rounded-xl border border-fd-border bg-fd-card backdrop-blur-xl shadow-xl z-[100]"
>
{/* Search Input */}
<div className="p-2 border-b border-fd-border">
<div className="relative">
<Search
className={`absolute left-3 top-1/2 transform -translate-y-1/2 ${ICON_SIZE} text-fd-muted-foreground`}
/>
<input
type="text"
placeholder="Search models..."
value={modelSearchQuery}
onChange={(e) => setModelSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 text-sm rounded-lg focus:outline-none focus:ring-none transition-colors"
/>
</div>
</div>
<div
className="p-2 max-h-72 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-fd-muted/50 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-fd-muted/70"
style={{
scrollbarWidth: "thin",
scrollbarColor: "rgba(156, 163, 175, 0.5) transparent",
}}
>
{Object.entries(filteredModels).length === 0 ? (
<div className="px-3 py-8 text-center text-sm text-fd-muted-foreground">
No models found matching "{modelSearchQuery}
"
</div>
) : (
Object.entries(filteredModels).map(
([category, models]) => (
<div key={category} className="mb-3 last:mb-0">
<div className="px-3 py-1 text-xs font-semibold text-fd-muted-foreground uppercase tracking-wide">
{category}
</div>
{models.map((model) => (
<button
key={model.id}
onClick={() => {
setSelectedModel(model.id);
setShowModelDropdown(false);
setModelSearchQuery(""); // Clear search when selecting
}}
className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-left hover:bg-fd-accent transition-colors",
selectedModel === model.id && "bg-fd-accent"
)}
>
{model.icon}
<span className="text-sm truncate w-40 md:truncate-none md:w-auto">
{model.name}
</span>
</button>
))}
</div>
)
)
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div className="flex justify-center gap-3">
{/* Send Button */}
<button
type="button"
onClick={handleSend}
disabled={!message.trim()}
className="p-3 rounded-lg bg-fd-primary text-fd-primary-foreground hover:bg-fd-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send className="h-4 w-4" />
</button>
</div>
</div>
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.txt,.doc,.docx"
onChange={handleFileChange}
className="hidden"
/>
</div>
</div>
);
}
Usage
import ChatInputBox from "@/components/ui/chat-input-box";
export default function ChatPage() {
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">AI Chat</h1>
<ChatInputBox />
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | undefined | Optional additional CSS classes |
Customizing Models
Update the AI_MODELS object to add your own models:
const AI_MODELS = {
claude: [
{
id: "claude-sonnet-4",
name: "Claude Sonnet 4",
icon: <SiClaude className="h-4 w-4" />,
},
],
openai: [
{
id: "gpt-4",
name: "GPT-4",
icon: <TbBrandOpenai className="h-4 w-4" />,
},
],
// Add new providers here
};Each model needs:
id: unique identifiername: display nameicon: React icon component withh-4 w-4class
