About this snippet

A message box for an AI chat that takes files as well as text. Attach files with the plus button, paste an image right into the box, or drag files onto it. Images show as thumbnails and other files as chips with their type and size, each with its own remove button. Enter sends and Shift+Enter adds a new line, and while the reply is on its way the send button turns into a stop button.

Each file is checked against the limits (four files, 10 MB each, an allowed extension and not empty), and anything that fails gets a plain message under the box. Thumbnails use URL.createObjectURL, and the URL is revoked when you remove the file. The keydown handler checks isComposing and keyCode 229, so pressing Enter to confirm a word in a Japanese or Chinese input method does not send the message, even in Safari 26 and earlier, which report isComposing as false on that key.

The two sample files are File objects made in the page, one of them drawn on a canvas, so the demo shows chips without an upload. Nothing leaves the browser, and the reply only lists what it received. To upload for real, send the files to your own endpoint with FormData. The box sits in the middle until the first message, then moves to the bottom while the messages scroll above it.

Size
17.7 KB
Uses
HTML, CSS, JavaScript
Loads
Nothing external
Views
23
<main class="chat">
  <div class="messages" id="messages" role="log" aria-label="Conversation" tabindex="0">
    <div class="empty" id="empty">
      <h1>What should we look at?</h1>
      <p>Attach files with the plus button, paste an image, or drop files on the message box.</p>
    </div>
  </div>
  <form class="composer" id="composer">
    <p class="drop-hint" aria-hidden="true">Drop files to attach</p>
    <ul class="attachments" id="attachments" aria-label="Attached files"></ul>
    <label class="sr-only" for="prompt">Message</label>
    <textarea id="prompt" rows="1" placeholder="Ask about your files"></textarea>
    <div class="composer-bar">
      <button type="button" class="icon-btn" id="attach" aria-label="Attach files">
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"></path></svg>
      </button>
      <input type="file" id="file-input" multiple accept="image/png,image/jpeg,image/gif,image/webp,.pdf,.txt,.md,.csv,.json" hidden>
      <span class="hint">Enter to send, Shift+Enter for a new line</span>
      <button type="submit" class="send-btn" id="send" aria-label="Send message" disabled>
        <svg class="icon-send" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M6 11l6-6 6 6"></path></svg>
        <svg class="icon-stop" viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"></rect></svg>
      </button>
    </div>
  </form>
  <p class="error" id="error" role="alert"></p>
</main>
*, *::before, *::after {
  box-sizing: border-box;
}

:root {
  --page: #f8fafc;
  --card: #ffffff;
  --text: #0f172a;
  --muted: #5b6678;
  --line: #d5dce6;
  --soft: #eef2f7;
  --accent: #2563eb;
  --error: #b91c1c;
  --shadow: 0 1px 2px rgba(15, 23, 42, 0.05), 0 8px 24px -10px rgba(15, 23, 42, 0.18);
}

body {
  font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  margin: 0;
  background: var(--page);
  color: var(--text);
}

.chat {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 12px;
  max-width: 700px;
  min-height: 100vh;
  margin: 0 auto;
  padding: 20px 16px;
}

.messages {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.chat.has-messages {
  height: 100vh;
  height: 100dvh;
}

.chat.has-messages .messages {
  flex: 1;
  min-height: 0;
  margin: -4px;
  padding: 4px;
  overflow-y: auto;
}

.chat.has-messages .messages::before {
  content: "";
  margin-top: auto;
}

.messages:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
  border-radius: 10px;
}

.empty {
  margin: 0 auto 12px;
  text-align: center;
}

.empty h1 {
  margin: 0 0 8px;
  font-size: 1.5rem;
}

.empty p {
  max-width: 360px;
  margin: 0 auto;
  color: var(--muted);
  line-height: 1.6;
}

.msg {
  max-width: 85%;
  margin: 0;
  line-height: 1.55;
  overflow-wrap: anywhere;
}

.msg-user {
  align-self: flex-end;
  display: grid;
  justify-items: end;
  gap: 8px;
}

.msg-user .msg-text {
  margin: 0;
  padding: 10px 16px;
  border-radius: 18px 18px 6px 18px;
  background: var(--soft);
}

.msg-files {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  align-items: flex-end;
  gap: 6px;
}

.msg-files img {
  width: 72px;
  height: 72px;
  border-radius: 12px;
  object-fit: cover;
}

.msg-file {
  padding: 6px 10px;
  border-radius: 10px;
  background: var(--soft);
  font-size: 0.8125rem;
}

.msg-ai {
  align-self: flex-start;
}

.msg-ai.is-muted {
  color: var(--muted);
  font-style: italic;
}

.typing {
  display: inline-flex;
  gap: 4px;
  padding: 8px 0;
}

.typing span {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--muted);
  animation: typing 1.2s ease-in-out infinite;
}

.typing span:nth-child(2) {
  animation-delay: 0.15s;
}

.typing span:nth-child(3) {
  animation-delay: 0.3s;
}

.composer {
  position: relative;
  padding: 10px 10px 8px;
  border: 1px solid var(--line);
  border-radius: 22px;
  background: var(--card);
  box-shadow: var(--shadow);
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.composer:focus-within {
  border-color: var(--accent);
  box-shadow: var(--shadow), 0 0 0 3px rgba(37, 99, 235, 0.18);
}

.drop-hint {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: none;
  place-items: center;
  margin: 0;
  border: 2px dashed var(--accent);
  border-radius: inherit;
  background: var(--card);
  color: var(--accent);
  font-weight: 600;
}

.composer.is-dragging .drop-hint {
  display: grid;
}

.attachments {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 0;
  padding: 2px 4px 10px;
  list-style: none;
}

.attachments:empty {
  display: none;
}

.chip {
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
  height: 56px;
  border-radius: 12px;
  background: var(--soft);
}

.chip img {
  width: 56px;
  height: 56px;
  border-radius: 12px;
  object-fit: cover;
}

.chip-file {
  max-width: 220px;
  padding: 0 34px 0 8px;
}

.chip-ext {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 38px;
  height: 38px;
  border-radius: 8px;
  background: var(--accent);
  color: #ffffff;
  font-size: 0.6875rem;
  font-weight: 700;
}

.chip-meta {
  display: grid;
  min-width: 0;
  font-size: 0.8125rem;
  line-height: 1.35;
}

.chip-name {
  overflow: hidden;
  font-weight: 600;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.chip-size {
  color: var(--muted);
}

.chip-remove {
  position: absolute;
  top: -6px;
  right: -6px;
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  padding: 0;
  border: 2px solid var(--card);
  border-radius: 50%;
  background: var(--text);
  color: var(--card);
  cursor: pointer;
}

.chip-file .chip-remove {
  top: 50%;
  right: 6px;
  border-color: var(--soft);
  transform: translateY(-50%);
}

.chip-remove svg {
  width: 12px;
  height: 12px;
}

textarea {
  display: block;
  width: 100%;
  max-height: 160px;
  padding: 6px 8px;
  border: 0;
  outline: 0;
  resize: none;
  background: transparent;
  color: var(--text);
  font: inherit;
  line-height: 1.5;
}

textarea::placeholder {
  color: var(--muted);
}

.composer-bar {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 6px;
}

.hint {
  flex: 1;
  font-size: 0.8125rem;
  color: var(--muted);
  text-align: right;
}

.icon-btn,
.send-btn {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 38px;
  height: 38px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  cursor: pointer;
}

.icon-btn {
  background: var(--soft);
  color: var(--text);
}

.icon-btn:hover {
  background: var(--line);
}

.send-btn {
  background: var(--text);
  color: var(--card);
}

.send-btn:disabled {
  background: var(--line);
  color: var(--muted);
  cursor: default;
}

.chat svg {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.icon-stop {
  display: none;
  fill: currentColor;
  stroke: none;
}

.composer.is-busy .icon-send {
  display: none;
}

.composer.is-busy .icon-stop {
  display: block;
}

.icon-btn:focus-visible,
.send-btn:focus-visible,
.chip-remove:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.error {
  min-height: 1.5em;
  margin: 0;
  padding: 0 8px;
  color: var(--error);
  font-size: 0.875rem;
  overflow-wrap: anywhere;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@keyframes typing {
  0%, 60%, 100% {
    opacity: 0.35;
    transform: translateY(0);
  }
  30% {
    opacity: 1;
    transform: translateY(-3px);
  }
}

@media (max-width: 480px) {
  .hint {
    visibility: hidden;
  }
}

@media (pointer: coarse) {
  .icon-btn,
  .send-btn {
    width: 44px;
    height: 44px;
  }

  textarea {
    min-height: 44px;
  }

  .chip-remove::before {
    content: "";
    position: absolute;
    inset: -10px;
  }
}

@media (prefers-color-scheme: dark) {
  :root {
    --page: #0f172a;
    --card: #1e293b;
    --text: #e2e8f0;
    --muted: #a3afc2;
    --line: #3b4a61;
    --soft: #2b3a52;
    --accent: #60a5fa;
    --error: #fca5a5;
    --shadow: 0 8px 24px -10px rgba(0, 0, 0, 0.5);
  }

  .chip-ext {
    color: #0f172a;
  }
}

@media (prefers-reduced-motion: reduce) {
  .typing span {
    animation: none;
  }
}
const MAX_FILES = 4;
const MAX_SIZE = 10 * 1024 * 1024;
const IMAGES = ["png", "jpg", "jpeg", "gif", "webp"];
const ALLOWED = IMAGES.concat(["pdf", "txt", "md", "csv", "json"]);

const chat = document.querySelector(".chat");
const composer = document.getElementById("composer");
const prompt = document.getElementById("prompt");
const list = document.getElementById("attachments");
const fileInput = document.getElementById("file-input");
const attachBtn = document.getElementById("attach");
const sendBtn = document.getElementById("send");
const messages = document.getElementById("messages");
const empty = document.getElementById("empty");
const error = document.getElementById("error");

let files = [];
let replyTimer = null;
let pending = null;

function formatSize(bytes) {
  if (bytes < 1024) return bytes + " B";
  if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + " KB";
  return (bytes / 1024 / 1024).toFixed(1) + " MB";
}

function extension(name) {
  const dot = name.lastIndexOf(".");
  return dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
}

function isImage(file) {
  return IMAGES.includes(extension(file.name));
}

function updateSend() {
  const busy = composer.classList.contains("is-busy");
  const off = !busy && !prompt.value.trim() && files.length === 0;
  if (off && document.activeElement === sendBtn) prompt.focus();
  sendBtn.disabled = off;
}

function scrollToEnd() {
  messages.scrollTop = messages.scrollHeight;
}

function autosize() {
  prompt.style.height = "auto";
  prompt.style.height = Math.min(prompt.scrollHeight, 160) + "px";
}

// Check each file, then add the ones that pass
function addFiles(incoming) {
  const problems = [];
  Array.from(incoming).forEach(function(file) {
    if (files.length >= MAX_FILES) {
      problems.push("You can attach up to " + MAX_FILES + " files.");
    } else if (!ALLOWED.includes(extension(file.name))) {
      problems.push(file.name + " is not a supported file type.");
    } else if (file.size === 0) {
      problems.push(file.name + " is empty.");
    } else if (file.size > MAX_SIZE) {
      problems.push(file.name + " is over " + formatSize(MAX_SIZE) + ".");
    } else if (files.some(function(f) { return f.file.name === file.name && f.file.size === file.size; })) {
      problems.push(file.name + " is already attached.");
    } else {
      const entry = { file: file, url: isImage(file) ? URL.createObjectURL(file) : null };
      files.push(entry);
      list.append(renderChip(entry));
    }
  });
  error.textContent = problems.filter(function(p, i) { return problems.indexOf(p) === i; }).join(" ");
  updateSend();
}

function renderChip(entry) {
  const li = document.createElement("li");
  const remove = document.createElement("button");
  li.className = "chip " + (entry.url ? "chip-image" : "chip-file");
  if (entry.url) {
    const img = document.createElement("img");
    img.src = entry.url;
    img.alt = entry.file.name;
    li.append(img);
  } else {
    const ext = document.createElement("span");
    const meta = document.createElement("span");
    const name = document.createElement("span");
    const size = document.createElement("span");
    ext.className = "chip-ext";
    ext.textContent = extension(entry.file.name).toUpperCase();
    meta.className = "chip-meta";
    name.className = "chip-name";
    name.textContent = entry.file.name;
    size.className = "chip-size";
    size.textContent = formatSize(entry.file.size);
    meta.append(name, size);
    li.append(ext, meta);
  }
  remove.type = "button";
  remove.className = "chip-remove";
  remove.setAttribute("aria-label", "Remove " + entry.file.name);
  remove.append(xIcon());
  remove.addEventListener("click", function() {
    files = files.filter(function(f) { return f !== entry; });
    if (entry.url) URL.revokeObjectURL(entry.url);
    li.remove();
    error.textContent = "";
    updateSend();
    prompt.focus();
  });
  li.append(remove);
  return li;
}

function xIcon() {
  const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
  const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
  svg.setAttribute("viewBox", "0 0 24 24");
  svg.setAttribute("aria-hidden", "true");
  path.setAttribute("d", "M6 6l12 12M18 6L6 18");
  svg.append(path);
  return svg;
}

function addMessage(className, text) {
  const msg = document.createElement("div");
  msg.className = "msg " + className;
  if (text) msg.textContent = text;
  empty.hidden = true;
  chat.classList.add("has-messages");
  messages.append(msg);
  scrollToEnd();
  return msg;
}

function describe(entry) {
  const kind = isImage(entry.file) ? "image" : extension(entry.file.name).toUpperCase() + " file";
  return entry.file.name + " (" + kind + ", " + formatSize(entry.file.size) + ")";
}

function send() {
  const text = prompt.value.trim();
  const sent = files;
  if (!text && sent.length === 0) return;

  const msg = addMessage("msg-user");
  if (sent.length) {
    const row = document.createElement("div");
    row.className = "msg-files";
    sent.forEach(function(entry) {
      if (entry.url) {
        const img = document.createElement("img");
        img.src = entry.url;
        img.alt = entry.file.name;
        row.append(img);
      } else {
        const tag = document.createElement("span");
        tag.className = "msg-file";
        tag.textContent = entry.file.name;
        row.append(tag);
      }
    });
    msg.append(row);
  }
  if (text) {
    const p = document.createElement("p");
    p.className = "msg-text";
    p.textContent = text;
    msg.append(p);
  }

  files = [];
  list.textContent = "";
  prompt.value = "";
  error.textContent = "";
  autosize();

  // Show a typing indicator, then reply with what was sent
  pending = addMessage("msg-ai");
  const label = document.createElement("span");
  const dots = document.createElement("span");
  label.className = "sr-only";
  label.textContent = "Assistant is typing";
  dots.className = "typing";
  dots.setAttribute("aria-hidden", "true");
  dots.append(document.createElement("span"), document.createElement("span"), document.createElement("span"));
  pending.append(label, dots);
  setBusy(true);
  replyTimer = setTimeout(function() {
    pending.textContent = sent.length
      ? "Received " + sent.length + (sent.length === 1 ? " file: " : " files: ") + sent.map(describe).join(", ") + ". Nothing left your browser."
      : "Received your message. Nothing left your browser.";
    pending = null;
    scrollToEnd();
    setBusy(false);
  }, 1400);
}

function stopReply() {
  clearTimeout(replyTimer);
  if (pending) {
    pending.textContent = "Stopped.";
    pending.classList.add("is-muted");
    pending = null;
  }
  setBusy(false);
}

function setBusy(busy) {
  composer.classList.toggle("is-busy", busy);
  sendBtn.setAttribute("aria-label", busy ? "Stop reply" : "Send message");
  updateSend();
}

composer.addEventListener("submit", function(e) {
  e.preventDefault();
  if (composer.classList.contains("is-busy")) {
    stopReply();
  } else {
    send();
  }
});

// Ignore the second click of a double click
sendBtn.addEventListener("click", function(e) {
  if (e.detail > 1) e.preventDefault();
});

// Safari 26 and older fire this key after compositionend
prompt.addEventListener("keydown", function(e) {
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
    e.preventDefault();
    if (!composer.classList.contains("is-busy")) send();
  }
});

prompt.addEventListener("input", function() {
  autosize();
  updateSend();
});

// Office apps copy text with an image too, so keep the text
prompt.addEventListener("paste", function(e) {
  const data = e.clipboardData;
  if (!data || !data.files.length || data.types.includes("text/plain")) return;
  e.preventDefault();
  addFiles(data.files);
});

attachBtn.addEventListener("click", function() {
  fileInput.click();
});

fileInput.addEventListener("change", function() {
  addFiles(fileInput.files);
  fileInput.value = "";
  prompt.focus();
});

// Highlight the box while files are dragged over it
let dragDepth = 0;
composer.addEventListener("dragenter", function(e) {
  if (!e.dataTransfer.types.includes("Files")) return;
  dragDepth++;
  composer.classList.add("is-dragging");
});

composer.addEventListener("dragleave", function() {
  dragDepth = Math.max(0, dragDepth - 1);
  if (dragDepth === 0) composer.classList.remove("is-dragging");
});

composer.addEventListener("drop", function(e) {
  dragDepth = 0;
  composer.classList.remove("is-dragging");
  if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
});

// Stop a dropped file from opening, but let text drop in
window.addEventListener("dragover", function(e) {
  if (e.dataTransfer.types.includes("Files")) e.preventDefault();
});

window.addEventListener("drop", function(e) {
  if (e.dataTransfer.types.includes("Files")) e.preventDefault();
});

// Two sample files so the demo shows chips without an upload
function addSampleFiles() {
  const canvas = document.createElement("canvas");
  const ctx = canvas.getContext("2d");
  canvas.width = 160;
  canvas.height = 120;
  const gradient = ctx.createLinearGradient(0, 0, 160, 120);
  gradient.addColorStop(0, "#3d7bfa");
  gradient.addColorStop(1, "#ef4f98");
  ctx.fillStyle = gradient;
  ctx.fillRect(0, 0, 160, 120);
  ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
  ctx.beginPath();
  ctx.arc(108, 48, 22, 0, Math.PI * 2);
  ctx.fill();
  canvas.toBlob(function(blob) {
    addFiles([
      new File([blob], "sketch.png", { type: "image/png" }),
      new File(["Launch checklist\n1. Final copy review\n2. Test the signup form\n"], "notes.txt", { type: "text/plain" })
    ]);
  });
}

addSampleFiles();
Esc
↑↓ moveEnter select

Start from a template

Open an .html file, or HTML, CSS, JS, Markdown, Less or TypeScript files. You can also drop them here. Styles and scripts go into their panes.

Share

Source

Keyboard shortcuts

Save