Chat Input
Original · freechat composer with attach and send
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/chat-input.json"use client";
import {
useEffect,
useId,
useRef,
useState,
type ChangeEvent,
type DragEvent,
type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Attachment = {
id: string;
name: string;
size: number;
};
type Message = {
id: string;
author: "you" | "agent";
text: string;
files: Attachment[];
time: string;
};
const MAX_CHARS = 1200;
const MAX_FILES = 4;
const MAX_ROWS_PX = 176;
const SEED: Message[] = [
{
id: "m1",
author: "agent",
text: "Morning. The staging deploy finished at 08:12 and the checkout route is back under 400ms. What do you want to look at first?",
files: [],
time: "08:14",
},
{
id: "m2",
author: "you",
text: "The invoice PDF is still rendering the old logo. I'll send over the export and the failing snapshot.",
files: [],
time: "08:16",
},
];
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function clockNow(): string {
const d = new Date();
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
return `${h}:${m}`;
}
function replyFor(files: Attachment[]): string {
if (files.length > 0) {
return `Got ${files.length} file${files.length > 1 ? "s" : ""}. Opening them against the render worker now — I'll post the diff in this thread before standup.`;
}
return "Noted. I'll pull the branch and reproduce it locally, then reply here with what I find.";
}
export default function ChatInput() {
const [value, setValue] = useState<string>("");
const [files, setFiles] = useState<Attachment[]>([]);
const [messages, setMessages] = useState<Message[]>(SEED);
const [dragging, setDragging] = useState<boolean>(false);
const [thinking, setThinking] = useState<boolean>(false);
const [status, setStatus] = useState<string>("");
const baseId = useId();
const inputId = `${baseId}-composer`;
const countId = `${baseId}-count`;
const taRef = useRef<HTMLTextAreaElement | null>(null);
const fileRef = useRef<HTMLInputElement | null>(null);
const logRef = useRef<HTMLDivElement | null>(null);
const seq = useRef<number>(0);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const reduced = useReducedMotion();
const over = value.length > MAX_CHARS;
const canSend = (value.trim().length > 0 || files.length > 0) && !over;
const nextId = (): string => {
seq.current += 1;
return `${baseId}-${seq.current}`;
};
useEffect(() => {
const el = taRef.current;
if (!el) return;
el.style.height = "0px";
el.style.height = `${Math.min(el.scrollHeight, MAX_ROWS_PX)}px`;
}, [value]);
useEffect(() => {
const el = logRef.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior: reduced ? "auto" : "smooth" });
}, [messages, thinking, reduced]);
useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
const addFiles = (list: FileList | null): void => {
if (!list || list.length === 0) return;
const room = MAX_FILES - files.length;
if (room <= 0) {
setStatus(`Attachment limit reached. ${MAX_FILES} files maximum.`);
return;
}
const picked = Array.from(list);
const kept: Attachment[] = picked.slice(0, room).map((f) => ({
id: nextId(),
name: f.name,
size: f.size,
}));
const dropped = picked.length - kept.length;
setFiles((prev) => [...prev, ...kept]);
setStatus(
dropped > 0
? `Attached ${kept.length} file${kept.length > 1 ? "s" : ""}. ${dropped} skipped, limit is ${MAX_FILES}.`
: `Attached ${kept.length} file${kept.length > 1 ? "s" : ""}.`,
);
};
const removeFile = (id: string, name: string): void => {
setFiles((prev) => prev.filter((f) => f.id !== id));
setStatus(`Removed ${name}.`);
taRef.current?.focus();
};
const send = (): void => {
if (!canSend) return;
const text = value.trim();
const sentFiles = files;
setMessages((prev) => [
...prev,
{
id: nextId(),
author: "you",
text: text.length > 0 ? text : "Sending these over.",
files: sentFiles,
time: clockNow(),
},
]);
setValue("");
setFiles([]);
setStatus("Message sent.");
setThinking(true);
if (fileRef.current) fileRef.current.value = "";
taRef.current?.focus();
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
setMessages((prev) => [
...prev,
{
id: nextId(),
author: "agent",
text: replyFor(sentFiles),
files: [],
time: clockNow(),
},
]);
setThinking(false);
setStatus("New reply from Priya Raman.");
}, 1100);
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
const onPick = (e: ChangeEvent<HTMLInputElement>): void => {
addFiles(e.target.files);
e.target.value = "";
};
const onDragLeave = (e: DragEvent<HTMLDivElement>): void => {
const next = e.relatedTarget;
if (next instanceof Node && e.currentTarget.contains(next)) return;
setDragging(false);
};
const onDrop = (e: DragEvent<HTMLDivElement>): void => {
e.preventDefault();
setDragging(false);
addFiles(e.dataTransfer.files);
};
return (
<section className="relative w-full bg-slate-50 px-4 py-20 sm:px-6 sm:py-28 dark:bg-slate-950">
<style>{`
@keyframes cinpx-dot {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-3px); opacity: 1; }
}
@keyframes cinpx-sweep {
from { transform: translateX(-100%); }
to { transform: translateX(100%); }
}
.cinpx-dot { animation: cinpx-dot 1.1s ease-in-out infinite; }
.cinpx-dot:nth-child(2) { animation-delay: 0.14s; }
.cinpx-dot:nth-child(3) { animation-delay: 0.28s; }
.cinpx-sweep { animation: cinpx-sweep 1.6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.cinpx-dot, .cinpx-sweep { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-2xl">
<header className="mb-8">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Thread · render-worker
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-50">
Composer with attachments
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Enter sends, Shift + Enter starts a new line. Drop files anywhere on
the box or use the paperclip — up to {MAX_FILES} at a time.
</p>
</header>
<div
ref={logRef}
tabIndex={0}
role="region"
aria-label="Conversation with Priya Raman"
className="max-h-80 overflow-y-auto rounded-2xl border border-slate-200 bg-white p-4 shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 sm:p-5 dark:border-slate-800 dark:bg-slate-900 dark:focus-visible:ring-offset-slate-950"
>
<ul className="flex flex-col gap-4">
{messages.map((m) => (
<li
key={m.id}
className={m.author === "you" ? "flex justify-end" : "flex justify-start"}
>
<div className="max-w-[85%]">
<div className="mb-1 flex items-baseline gap-2 text-xs text-slate-500 dark:text-slate-500">
<span className="font-medium text-slate-700 dark:text-slate-300">
{m.author === "you" ? "You" : "Priya Raman"}
</span>
<span>{m.time}</span>
</div>
<div
className={
m.author === "you"
? "rounded-2xl rounded-br-sm bg-indigo-600 px-4 py-2.5 text-sm leading-relaxed text-white"
: "rounded-2xl rounded-bl-sm bg-slate-100 px-4 py-2.5 text-sm leading-relaxed text-slate-800 dark:bg-slate-800 dark:text-slate-200"
}
>
{m.text}
{m.files.length > 0 && (
<ul className="mt-2.5 flex flex-col gap-1.5 border-t border-white/25 pt-2.5 dark:border-white/10">
{m.files.map((f) => (
<li key={f.id} className="flex items-center gap-2 text-xs">
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-3.5 w-3.5 shrink-0"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />
</svg>
<span className="truncate">{f.name}</span>
<span className="shrink-0 opacity-70">
{formatBytes(f.size)}
</span>
</li>
))}
</ul>
)}
</div>
</div>
</li>
))}
</ul>
{thinking && (
<div className="mt-4 flex items-center gap-2">
<span className="text-xs text-slate-500 dark:text-slate-500">
Priya is typing
</span>
<span className="flex items-center gap-1" aria-hidden="true">
<span className="cinpx-dot h-1.5 w-1.5 rounded-full bg-slate-400 dark:bg-slate-500" />
<span className="cinpx-dot h-1.5 w-1.5 rounded-full bg-slate-400 dark:bg-slate-500" />
<span className="cinpx-dot h-1.5 w-1.5 rounded-full bg-slate-400 dark:bg-slate-500" />
</span>
</div>
)}
</div>
<div
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={onDragLeave}
onDrop={onDrop}
className={`relative mt-4 overflow-hidden rounded-2xl border bg-white shadow-sm transition-colors dark:bg-slate-900 ${
dragging
? "border-indigo-500 ring-2 ring-indigo-500/30 dark:border-indigo-400"
: over
? "border-rose-400 dark:border-rose-500/70"
: "border-slate-200 focus-within:border-indigo-400 dark:border-slate-800 dark:focus-within:border-indigo-500/60"
}`}
>
{thinking && !reduced && (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-0 h-px overflow-hidden"
>
<span className="cinpx-sweep block h-px w-1/3 bg-gradient-to-r from-transparent via-indigo-500 to-transparent" />
</span>
)}
<AnimatePresence initial={false}>
{files.length > 0 && (
<motion.div
initial={reduced ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduced ? { opacity: 0 } : { height: 0, opacity: 0 }}
transition={{ duration: reduced ? 0 : 0.18, ease: "easeOut" }}
className="overflow-hidden"
>
<ul className="flex flex-wrap gap-2 border-b border-slate-200 p-3 dark:border-slate-800">
<AnimatePresence initial={false}>
{files.map((f) => (
<motion.li
key={f.id}
layout={!reduced}
initial={reduced ? false : { opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.9 }}
transition={{ duration: reduced ? 0 : 0.15 }}
className="flex items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 py-1.5 pl-2.5 pr-1.5 dark:border-slate-700 dark:bg-slate-800"
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4 shrink-0 text-indigo-600 dark:text-indigo-400"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" />
</svg>
<span className="max-w-[10rem] truncate text-xs font-medium text-slate-700 dark:text-slate-200">
{f.name}
</span>
<span className="text-[11px] tabular-nums text-slate-500 dark:text-slate-400">
{formatBytes(f.size)}
</span>
<button
type="button"
onClick={() => removeFile(f.id, f.name)}
aria-label={`Remove ${f.name}`}
className="rounded-md p-1 text-slate-500 transition-colors hover:bg-slate-200 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-1 focus-visible:ring-offset-slate-50 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-800"
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
</motion.div>
)}
</AnimatePresence>
<label htmlFor={inputId} className="sr-only">
Message Priya Raman
</label>
<textarea
id={inputId}
ref={taRef}
rows={1}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Describe what broke, or drop the failing snapshot in here…"
aria-describedby={countId}
aria-invalid={over}
className="block w-full resize-none bg-transparent px-4 pt-4 text-sm leading-relaxed text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-slate-100 dark:placeholder:text-slate-500"
/>
<div className="flex items-center justify-between gap-3 px-3 pb-3 pt-2">
<div className="flex items-center gap-1">
<input
ref={fileRef}
id={`${baseId}-file`}
type="file"
multiple
onChange={onPick}
tabIndex={-1}
className="sr-only"
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={files.length >= MAX_FILES}
aria-label={`Attach files, ${files.length} of ${MAX_FILES} used`}
className="rounded-lg p-2 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:focus-visible:ring-offset-slate-900"
>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21.44 11.05l-8.49 8.49a5.5 5.5 0 0 1-7.78-7.78l8.49-8.49a3.5 3.5 0 0 1 4.95 4.95l-8.49 8.49a1.5 1.5 0 0 1-2.12-2.12l7.78-7.78" />
</svg>
</button>
<span
id={countId}
className={`ml-1 text-[11px] tabular-nums ${
over
? "font-medium text-rose-600 dark:text-rose-400"
: "text-slate-400 dark:text-slate-500"
}`}
>
{value.length} / {MAX_CHARS}
</span>
</div>
<button
type="button"
onClick={send}
disabled={!canSend}
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-3.5 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none dark:focus-visible:ring-offset-slate-900 dark:disabled:bg-slate-800 dark:disabled:text-slate-600"
>
Send
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m5 12 14-7-4.5 7L19 19 5 12Z" />
</svg>
</button>
</div>
{dragging && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-2xl bg-indigo-50/90 dark:bg-indigo-950/80">
<p className="text-sm font-medium text-indigo-700 dark:text-indigo-300">
Drop to attach
</p>
</div>
)}
</div>
<p aria-live="polite" className="sr-only">
{status}
</p>
</div>
</section>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Chat Bubbles
Originalmessage bubble styles both sides

Chat Conversation
Originalfull conversation thread with avatars

Chat Support Widget
Originalfloating support chat widget

Chat Typing
Originaltyping indicator and delivery states

Chat Group
Originalgroup chat with member names

Chat List
Originalconversations list with unread counts

Chat AI
OriginalAI assistant chat interface

Chat Header
Originalchat window header with actions

Chat Attachments
Originalmessages with image and file attachments

Chat Reactions
Originalmessage reactions and replies

Chat Read Receipts
Originalread receipts and timestamps

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

