About this snippet

A chat widget for any website. The round button in the corner opens a panel with a greeting and three suggested questions, and the demo opens it on load so you can try it right away. Type a question or tap a suggestion, and three dots show while the reply is on its way. The new chat button starts over, and the close button or Esc hides the panel again.

Every reply comes from getReply, a function that returns a Promise of text. In the demo it matches keywords against three built-in answers after a short delay, and those answers explain the widget itself. Swap its body for a fetch to your own server route that calls a model, and the rest works unchanged. If the promise rejects, the chat shows a short error instead of waiting forever. Messages are added as plain text with append(), so whatever the model returns or the visitor types is never parsed as HTML.

The launcher has aria-expanded and aria-controls, the message list is a log region, and each message starts with a hidden You or Assistant label for screen readers. Focus moves to the input when you open the panel and back to the launcher when you close it. At 480 pixels wide or less the panel fills the screen and the page behind it becomes inert, so Tab stays in the chat. The main colors are custom properties on .chat-widget.

Size
14.2 KB
Uses
HTML, CSS, JavaScript
Loads
Nothing external
Views
57
<main class="demo-page">
  <h1>Chat widget demo</h1>
  <p>Open the chat from the round button in the corner. Press Esc while you are in it to close it.</p>
</main>
<div class="chat-widget" id="chat-widget">
  <section class="chat-panel" id="chat-panel" role="dialog" aria-labelledby="chat-title" inert>
    <header class="chat-head">
      <span class="chat-avatar" aria-hidden="true">
        <svg viewBox="0 0 24 24"><path d="M4 12a8 8 0 1 1 4.2 7l-4.2 1 1.1-3.9A8 8 0 0 1 4 12Z"></path></svg>
      </span>
      <div class="chat-id">
        <h2 class="chat-title" id="chat-title">Help assistant</h2>
        <p class="chat-sub">Demo replies from a script</p>
      </div>
      <button type="button" class="head-btn" id="chat-reset" aria-label="Start a new chat">
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12a8 8 0 1 0 2.4-5.7M4 4v4.5h4.5"></path></svg>
      </button>
      <button type="button" class="head-btn" id="chat-close" aria-label="Close chat">
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"></path></svg>
      </button>
    </header>
    <div class="chat-log" id="chat-log" role="log" aria-label="Messages" tabindex="0"></div>
    <form class="chat-form" id="chat-form">
      <label class="sr-only" for="chat-input">Message</label>
      <input id="chat-input" type="text" placeholder="Ask a question" autocomplete="off" maxlength="500">
      <button type="submit" class="chat-send" id="chat-send" aria-label="Send message" disabled>
        <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 19V5M6 11l6-6 6 6"></path></svg>
      </button>
    </form>
  </section>
  <button type="button" class="chat-launcher" id="chat-launcher" aria-expanded="false" aria-controls="chat-panel" aria-label="Open chat">
    <svg class="icon-open" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12a8 8 0 1 1 4.2 7l-4.2 1 1.1-3.9A8 8 0 0 1 4 12Z"></path></svg>
    <svg class="icon-close" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"></path></svg>
  </button>
</div>
*, *::before, *::after {
  box-sizing: border-box;
}

body {
  font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  margin: 0;
  background: #f8fafc;
  color: #0f172a;
}

.demo-page {
  max-width: 520px;
  padding: 48px 24px;
}

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

.demo-page p {
  margin: 0;
  color: #475569;
  line-height: 1.6;
}

.chat-widget {
  --chat-accent: #2563eb;
  --chat-accent-text: #ffffff;
  --chat-bg: #ffffff;
  --chat-text: #0f172a;
  --chat-muted: #5b6678;
  --chat-bot: #eef2f7;
  --chat-line: #d5dce6;
}

.chat-launcher {
  position: fixed;
  right: 20px;
  bottom: 20px;
  z-index: 20;
  display: grid;
  place-items: center;
  width: 56px;
  height: 56px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: var(--chat-accent);
  color: var(--chat-accent-text);
  box-shadow: 0 8px 24px -6px rgba(15, 23, 42, 0.35);
  cursor: pointer;
  transition: transform 0.2s ease;
}

.chat-launcher:hover {
  transform: scale(1.05);
}

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

.chat-launcher svg {
  grid-area: 1 / 1;
  width: 26px;
  height: 26px;
  transition: transform 0.2s ease, opacity 0.2s ease;
}

.icon-close,
.is-open .icon-open {
  opacity: 0;
  transform: rotate(-45deg) scale(0.6);
}

.is-open .icon-close {
  opacity: 1;
  transform: none;
}

.chat-panel {
  position: fixed;
  right: 20px;
  bottom: 88px;
  z-index: 20;
  display: flex;
  flex-direction: column;
  width: 360px;
  height: min(540px, calc(100vh - 112px));
  overflow: hidden;
  border-radius: 18px;
  background: var(--chat-bg);
  color: var(--chat-text);
  box-shadow: 0 24px 60px -16px rgba(15, 23, 42, 0.4), 0 0 0 1px rgba(15, 23, 42, 0.06);
  opacity: 0;
  visibility: hidden;
  transform: translateY(12px) scale(0.98);
  transform-origin: bottom right;
  transition: opacity 0.2s ease, transform 0.2s ease, visibility 0s linear 0.2s;
}

.is-open .chat-panel {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition: opacity 0.2s ease, transform 0.2s ease, visibility 0s;
}

.chat-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 14px 12px 14px 16px;
  background: var(--chat-accent);
  color: var(--chat-accent-text);
}

.chat-avatar {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.2);
}

.chat-avatar svg {
  width: 20px;
  height: 20px;
}

.chat-id {
  flex: 1;
  min-width: 0;
}

.chat-title {
  margin: 0;
  font-size: 1rem;
}

.chat-sub {
  margin: 2px 0 0;
  font-size: 0.8125rem;
  opacity: 0.9;
}

.head-btn {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: inherit;
  cursor: pointer;
}

.head-btn:hover {
  background: rgba(255, 255, 255, 0.18);
}

.chat-log {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: 10px;
  overflow-y: auto;
  padding: 16px;
}

.msg {
  max-width: 82%;
  margin: 0;
  padding: 10px 14px;
  border-radius: 16px;
  line-height: 1.5;
  overflow-wrap: anywhere;
}

.msg-bot {
  align-self: flex-start;
  border-bottom-left-radius: 6px;
  background: var(--chat-bot);
}

.msg-user {
  align-self: flex-end;
  border-bottom-right-radius: 6px;
  background: var(--chat-accent);
  color: var(--chat-accent-text);
}

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

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

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

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

.suggestions {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
}

.suggestion {
  min-height: 36px;
  padding: 7px 12px;
  border: 1px solid var(--chat-accent);
  border-radius: 12px;
  background: transparent;
  color: var(--chat-accent);
  font: inherit;
  font-size: 0.875rem;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
}

.suggestion:hover {
  background: var(--chat-bot);
}

.chat-form {
  display: flex;
  gap: 8px;
  padding: 12px;
  border-top: 1px solid var(--chat-line);
}

.chat-form input {
  flex: 1;
  min-width: 0;
  min-height: 42px;
  padding: 8px 12px;
  border: 1px solid var(--chat-line);
  border-radius: 12px;
  background: var(--chat-bg);
  color: var(--chat-text);
  font: inherit;
}

.chat-form input::placeholder {
  color: var(--chat-muted);
}

.chat-send {
  display: grid;
  place-items: center;
  flex-shrink: 0;
  width: 42px;
  height: 42px;
  padding: 0;
  border: 0;
  border-radius: 12px;
  background: var(--chat-accent);
  color: var(--chat-accent-text);
  cursor: pointer;
}

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

.chat-launcher:focus-visible,
.head-btn:focus-visible,
.suggestion:focus-visible,
.chat-send:focus-visible,
.chat-form input:focus-visible,
.chat-log:focus-visible {
  outline: 2px solid var(--chat-accent);
  outline-offset: 2px;
}

.head-btn:focus-visible {
  outline-color: var(--chat-accent-text);
  outline-offset: -2px;
}

.chat-log:focus-visible {
  outline-offset: -3px;
}

.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) {
  .chat-panel {
    inset: 0;
    width: auto;
    height: auto;
    border-radius: 0;
  }

  .is-open .chat-launcher {
    visibility: hidden;
  }
}

@media (pointer: coarse) {
  .head-btn,
  .chat-send,
  .suggestion,
  .chat-form input {
    min-width: 44px;
    min-height: 44px;
  }

  .chat-send {
    height: 44px;
  }
}

@media (prefers-color-scheme: dark) {
  body {
    background: #0b1120;
    color: #e2e8f0;
  }

  .demo-page p {
    color: #a3afc2;
  }

  .chat-widget {
    --chat-bg: #151e30;
    --chat-text: #e2e8f0;
    --chat-muted: #a3afc2;
    --chat-bot: #22304a;
    --chat-line: #334155;
  }

  .suggestion {
    border-color: #60a5fa;
    color: #93c5fd;
  }
}

@media (forced-colors: active) {
  .chat-launcher,
  .chat-panel,
  .msg,
  .head-btn,
  .chat-send {
    border: 1px solid CanvasText;
  }

  .typing i {
    forced-color-adjust: none;
    background: CanvasText;
  }
}

@media (prefers-reduced-motion: reduce) {
  .chat-panel,
  .is-open .chat-panel,
  .chat-launcher,
  .chat-launcher svg {
    transition: none;
  }

  .typing i {
    animation: none;
  }
}
const GREETING = "Hi, I'm a demo bot with three answers built in. Pick a question or type your own.";
const FALLBACK = "This demo only knows a few answers. Connect getReply to a model to answer anything.";
const SUGGESTIONS = ["How do I connect a real model?", "Where are messages stored?", "Can I change the colors?"];
const ANSWERS = [
  { words: ["model", "api", "connect", "real"], text: "Replace getReply with a fetch to your own server route that calls the model. Keep the API key on the server, never in this script." },
  { words: ["stored", "store", "saved", "history"], text: "Messages live in this page only. Nothing is saved, and the new chat button clears them." },
  { words: ["color", "colors", "theme", "brand"], text: "Change the custom properties on .chat-widget. The launcher, header, your bubbles and the send button all use --chat-accent." }
];

const widget = document.getElementById("chat-widget");
const panel = document.getElementById("chat-panel");
const launcher = document.getElementById("chat-launcher");
const closeBtn = document.getElementById("chat-close");
const resetBtn = document.getElementById("chat-reset");
const log = document.getElementById("chat-log");
const form = document.getElementById("chat-form");
const input = document.getElementById("chat-input");
const sendBtn = document.getElementById("chat-send");

let waiting = false;
let replyTimer = null;

// Swap this for a fetch to your server that returns text
function getReply(message) {
  const words = message.toLowerCase().match(/[a-z]+/g) || [];
  const match = ANSWERS.find(function(answer) {
    return answer.words.some(function(word) {
      return words.includes(word);
    });
  });
  return new Promise(function(resolve) {
    replyTimer = setTimeout(function() {
      resolve(match ? match.text : FALLBACK);
    }, 900);
  });
}

function addMessage(who, text) {
  const msg = document.createElement("p");
  const speaker = document.createElement("span");
  msg.className = "msg msg-" + who;
  speaker.className = "sr-only";
  speaker.textContent = who === "user" ? "You: " : "Assistant: ";
  msg.append(speaker, text);
  log.append(msg);
  log.scrollTop = log.scrollHeight;
  return msg;
}

function addSuggestions() {
  const box = document.createElement("div");
  box.className = "suggestions";
  SUGGESTIONS.forEach(function(text) {
    const chip = document.createElement("button");
    chip.type = "button";
    chip.className = "suggestion";
    chip.textContent = text;
    chip.addEventListener("click", function() {
      send(text);
    });
    box.append(chip);
  });
  log.append(box);
}

function updateSend() {
  sendBtn.disabled = waiting || !input.value.trim();
}

async function send(text) {
  const message = text.trim();
  if (!message || waiting) return;
  // Keep focus in the input as chips and Send go away
  if (panel.contains(document.activeElement)) input.focus();
  const chips = log.querySelector(".suggestions");
  if (chips) chips.remove();
  addMessage("user", message);
  input.value = "";
  waiting = true;
  updateSend();

  const typing = document.createElement("p");
  const label = document.createElement("span");
  typing.className = "msg msg-bot typing";
  label.className = "sr-only";
  label.textContent = "Assistant is typing";
  typing.append(label, document.createElement("i"), document.createElement("i"), document.createElement("i"));
  log.append(typing);
  log.scrollTop = log.scrollHeight;

  let reply;
  try {
    reply = await getReply(message);
  } catch (err) {
    reply = "The reply did not arrive. Check your connection and send it again.";
  }
  if (!typing.isConnected) return;
  typing.remove();
  addMessage("bot", reply);
  waiting = false;
  updateSend();
}

function reset() {
  clearTimeout(replyTimer);
  waiting = false;
  log.textContent = "";
  addMessage("bot", GREETING);
  addSuggestions();
  updateSend();
}

// A full-screen panel makes the page behind it inert
const fullScreen = window.matchMedia("(max-width: 480px)");
let inertParts = [];

function updateModal() {
  const modal = widget.classList.contains("is-open") && fullScreen.matches;
  panel.setAttribute("aria-modal", String(modal));
  inertParts.forEach(function(part) {
    part.inert = false;
  });
  inertParts = modal ? Array.from(document.body.children).filter(function(part) {
    return part !== widget && !part.inert;
  }) : [];
  inertParts.forEach(function(part) {
    part.inert = true;
  });
}

function openChat(moveFocus) {
  widget.classList.add("is-open");
  panel.inert = false;
  launcher.setAttribute("aria-expanded", "true");
  launcher.setAttribute("aria-label", "Close chat");
  updateModal();
  if (moveFocus) input.focus();
}

function closeChat() {
  const hadFocus = panel.contains(document.activeElement);
  widget.classList.remove("is-open");
  panel.inert = true;
  launcher.setAttribute("aria-expanded", "false");
  launcher.setAttribute("aria-label", "Open chat");
  updateModal();
  if (hadFocus) launcher.focus();
}

fullScreen.addEventListener("change", updateModal);

launcher.addEventListener("click", function() {
  if (widget.classList.contains("is-open")) {
    closeChat();
  } else {
    openChat(true);
  }
});

closeBtn.addEventListener("click", closeChat);
resetBtn.addEventListener("click", function() {
  reset();
  input.focus();
});

form.addEventListener("submit", function(e) {
  e.preventDefault();
  send(input.value);
});

input.addEventListener("input", updateSend);

widget.addEventListener("keydown", function(e) {
  if (e.key === "Escape" && widget.classList.contains("is-open")) closeChat();
});

reset();

// Open the panel for the demo, remove this line to start closed
openChat(false);
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