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