<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();