Web InnoventixFreeCode

Chat Input

Original · free

chat composer with attach and send

byWeb InnoventixReact + Tailwind
chatinput
Open

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
chat-input.tsx
"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 &middot; 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

motion

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 quote

Similar components

Browse all →