UI Components Hard
Chat Widget
Floating chat widget with bubble toggle, message thread, typing indicator, emoji picker, and auto-scroll. No dependencies.
Open in Lab
MCP
css vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #0f1117;
--surface: #16181f;
--surface2: #1e2130;
--border: #2a2d3a;
--text: #e2e8f0;
--text-muted: #64748b;
--accent: #818cf8;
--green: #34d399;
--red: #f87171;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.page {
text-align: center;
}
.page h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 8px;
}
.page p {
color: var(--text-muted);
font-size: 0.875rem;
}
/* -- Widget container -- */
.chat-widget {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
}
/* Launcher */
.chat-launcher {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--accent);
border: none;
cursor: pointer;
display: grid;
place-items: center;
position: relative;
box-shadow: 0 6px 24px rgba(129, 140, 248, 0.4);
transition: transform .2s, box-shadow .2s;
}
.chat-launcher:hover {
transform: scale(1.08);
box-shadow: 0 8px 30px rgba(129, 140, 248, 0.55);
}
.launcher-icon {
color: #fff;
display: grid;
place-items: center;
}
.launcher-badge {
position: absolute;
top: -4px;
right: -4px;
width: 18px;
height: 18px;
background: var(--red);
border-radius: 50%;
font-size: 0.62rem;
font-weight: 700;
color: #fff;
display: grid;
place-items: center;
border: 2px solid var(--bg);
}
/* Window */
.chat-window {
width: 340px;
max-width: calc(100vw - 48px);
height: 480px;
max-height: calc(100vh - 120px);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
display: flex;
flex-direction: column;
overflow: hidden;
animation: winIn .2s ease;
}
@keyframes winIn {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
/* Header */
.chat-header {
padding: 14px 16px;
background: var(--surface2);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.agent-wrap {
position: relative;
flex-shrink: 0;
}
.agent-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), #a5b4fc);
display: grid;
place-items: center;
font-size: 0.72rem;
font-weight: 700;
color: #fff;
}
.status-dot {
position: absolute;
bottom: 1px;
right: 1px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--green);
border: 2px solid var(--surface2);
}
.agent-info {
flex: 1;
min-width: 0;
}
.agent-name {
font-size: 0.875rem;
font-weight: 700;
}
.agent-status {
font-size: 0.7rem;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-min-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
display: grid;
place-items: center;
padding: 4px;
border-radius: 6px;
transition: color .15s;
flex-shrink: 0;
}
.chat-min-btn:hover {
color: var(--text);
}
/* Messages */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.chat-messages::-webkit-scrollbar {
width: 4px;
}
.chat-messages::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 2px;
}
.msg {
display: flex;
align-items: flex-end;
gap: 8px;
max-width: 85%;
animation: msgIn .2s ease;
}
@keyframes msgIn {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
.msg--bot {
align-self: flex-start;
}
.msg--user {
align-self: flex-end;
flex-direction: row-reverse;
}
.msg-av {
width: 28px;
height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), #a5b4fc);
display: grid;
place-items: center;
font-size: 0.6rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.msg-bubble {
padding: 9px 13px;
border-radius: 14px;
font-size: 0.82rem;
line-height: 1.5;
word-wrap: break-word;
overflow-wrap: break-word;
min-width: 0;
}
.msg--bot .msg-bubble {
background: var(--surface2);
border-bottom-left-radius: 4px;
color: var(--text);
}
.msg--user .msg-bubble {
background: var(--accent);
color: #fff;
border-bottom-right-radius: 4px;
}
.msg-time {
font-size: 0.65rem;
color: var(--text-muted);
margin-top: 4px;
display: block;
}
/* Typing indicator */
.typing-indicator {
display: flex;
gap: 4px;
align-items: center;
padding: 10px 14px;
background: var(--surface2);
border-radius: 14px;
border-bottom-left-radius: 4px;
width: fit-content;
}
.typing-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: typingBounce 1.2s infinite;
}
.typing-dot:nth-child(2) {
animation-delay: .2s;
}
.typing-dot:nth-child(3) {
animation-delay: .4s;
}
@keyframes typingBounce {
0%,
80%,
100% {
transform: none;
opacity: 0.4;
}
40% {
transform: translateY(-5px);
opacity: 1;
}
}
/* Emoji picker */
.emoji-picker {
padding: 8px;
border-top: 1px solid var(--border);
background: var(--surface2);
flex-shrink: 0;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 2px;
}
.emoji-btn {
background: none;
border: none;
font-size: 1.1rem;
cursor: pointer;
padding: 4px;
border-radius: 6px;
transition: background .1s;
line-height: 1;
}
.emoji-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
/* Input bar */
.chat-input-bar {
display: flex;
align-items: flex-end;
gap: 6px;
padding: 10px 12px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.emoji-toggle {
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
padding: 4px;
flex-shrink: 0;
}
.chat-textarea {
flex: 1;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
color: var(--text);
font-size: 0.82rem;
padding: 8px 12px;
outline: none;
font-family: inherit;
resize: none;
max-height: 100px;
overflow-y: auto;
transition: border-color .15s;
line-height: 1.4;
min-width: 0;
}
.chat-textarea:focus {
border-color: var(--accent);
}
.chat-textarea::placeholder {
color: var(--text-muted);
}
.send-btn {
width: 34px;
height: 34px;
border-radius: 9px;
background: var(--accent);
border: none;
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
transition: background .15s;
flex-shrink: 0;
}
.send-btn:hover {
background: #a5b4fc;
}
/* Responsive: small screens */
@media (max-width: 480px) {
.chat-widget {
bottom: 8px;
right: 8px;
}
.chat-window {
width: calc(100vw - 16px);
max-width: calc(100vw - 16px);
max-height: calc(100vh - 80px);
border-radius: 12px;
}
.chat-header {
padding: 10px 12px;
}
.chat-messages {
padding: 12px;
gap: 10px;
}
.chat-input-bar {
padding: 8px 10px;
}
.emoji-grid {
grid-template-columns: repeat(8, 1fr);
}
.chat-launcher {
width: 48px;
height: 48px;
}
}const launcher = document.getElementById("chatLauncher");
const chatWindow = document.getElementById("chatWindow");
const minBtn = document.getElementById("chatMinBtn");
const messages = document.getElementById("chatMessages");
const input = document.getElementById("chatInput");
const sendBtn = document.getElementById("sendBtn");
const emojiToggle = document.getElementById("emojiToggle");
const emojiPicker = document.getElementById("emojiPicker");
const emojiGrid = document.getElementById("emojiGrid");
const badge = document.getElementById("launcherBadge");
const EMOJIS = [
"😊",
"👍",
"🎉",
"🔥",
"❤️",
"✨",
"🚀",
"💡",
"🤝",
"😂",
"🙏",
"👏",
"🎁",
"⚡",
"💎",
"🛠️",
];
const BOT_REPLIES = [
"Thanks for reaching out! How can I help today?",
"Great question! Let me look into that for you.",
"Sure thing! I can help with that.",
"Got it! I'll check that and get back to you.",
"You're welcome! Is there anything else I can help with?",
];
let opened = false,
replyIdx = 0;
// Populate emoji grid
EMOJIS.forEach((e) => {
const btn = document.createElement("button");
btn.className = "emoji-btn";
btn.textContent = e;
btn.type = "button";
btn.addEventListener("click", () => {
input.value += e;
input.focus();
});
emojiGrid.appendChild(btn);
});
function formatTime() {
return new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function addMessage(text, isUser) {
const msg = document.createElement("div");
msg.className = "msg " + (isUser ? "msg--user" : "msg--bot");
msg.innerHTML = `
${!isUser ? '<div class="msg-av">SH</div>' : ""}
<div>
<div class="msg-bubble">${escapeHtml(text)}</div>
<span class="msg-time">${formatTime()}</span>
</div>
${isUser ? '<div class="msg-av" style="background:#4b5563">You</div>' : ""}
`;
messages.appendChild(msg);
scrollToBottom();
}
function escapeHtml(str) {
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
function showTyping() {
const el = document.createElement("div");
el.className = "msg msg--bot";
el.id = "typing";
el.innerHTML =
'<div class="msg-av">SH</div><div class="typing-indicator"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div>';
messages.appendChild(el);
scrollToBottom();
}
function hideTyping() {
document.getElementById("typing")?.remove();
}
function scrollToBottom() {
messages.scrollTop = messages.scrollHeight;
}
function sendMessage() {
const text = input.value.trim();
if (!text) return;
emojiPicker.hidden = true;
input.value = "";
input.style.height = "auto";
addMessage(text, true);
setTimeout(() => {
showTyping();
setTimeout(
() => {
hideTyping();
addMessage(BOT_REPLIES[replyIdx % BOT_REPLIES.length], false);
replyIdx++;
},
800 + Math.random() * 800
);
}, 400);
}
sendBtn?.addEventListener("click", sendMessage);
input?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Auto resize textarea
input?.addEventListener("input", () => {
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 100) + "px";
});
// Emoji toggle
emojiToggle?.addEventListener("click", () => {
emojiPicker.hidden = !emojiPicker.hidden;
});
// Launcher
launcher?.addEventListener("click", () => {
const isOpen = !chatWindow.hidden;
chatWindow.hidden = isOpen;
launcher.setAttribute("aria-expanded", String(!isOpen));
launcher.querySelector(".open-icon").hidden = !isOpen;
launcher.querySelector(".close-icon").hidden = isOpen;
badge.hidden = true;
if (!opened && !isOpen) {
opened = true;
setTimeout(
() => addMessage("Hi! 👋 Welcome to StealthHelp. How can I assist you today?", false),
300
);
}
});
minBtn?.addEventListener("click", () => {
chatWindow.hidden = true;
launcher.setAttribute("aria-expanded", "false");
launcher.querySelector(".open-icon").hidden = false;
launcher.querySelector(".close-icon").hidden = true;
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Chat Widget</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="page">
<h1>Chat Widget Demo</h1>
<p>Click the chat bubble in the bottom-right corner.</p>
</main>
<!-- ── Chat Widget ── -->
<div class="chat-widget" id="chatWidget">
<!-- Launcher -->
<button class="chat-launcher" id="chatLauncher" aria-label="Open chat" aria-expanded="false">
<span class="launcher-icon open-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
</span>
<span class="launcher-icon close-icon" hidden>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</span>
<span class="launcher-badge" id="launcherBadge">1</span>
</button>
<!-- Chat window -->
<div class="chat-window" id="chatWindow" hidden>
<!-- Header -->
<div class="chat-header">
<div class="agent-wrap">
<div class="agent-avatar">SH</div>
<div class="status-dot"></div>
</div>
<div class="agent-info">
<div class="agent-name">StealthHelp</div>
<div class="agent-status">Online · typically replies in seconds</div>
</div>
<button class="chat-min-btn" id="chatMinBtn" aria-label="Close chat">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Messages -->
<div class="chat-messages" id="chatMessages"></div>
<!-- Emoji picker -->
<div class="emoji-picker" id="emojiPicker" hidden>
<div class="emoji-grid" id="emojiGrid"></div>
</div>
<!-- Input bar -->
<div class="chat-input-bar">
<button class="emoji-toggle" id="emojiToggle" aria-label="Emoji picker">😊</button>
<textarea class="chat-textarea" id="chatInput" rows="1" placeholder="Type a message…"
aria-label="Message input"></textarea>
<button class="send-btn" id="sendBtn" aria-label="Send message">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5">
<line x1="22" y1="2" x2="11" y2="13" />
<polygon points="22 2 15 22 11 13 2 9 22 2" />
</svg>
</button>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Chat Widget
A floating bottom-right chat widget with an expandable chat window, message bubbles, a typing indicator with dots animation, an emoji picker, auto-scroll, and simulated bot replies.
Features
- Floating launcher button with unread badge
- Chat window opens/closes with spring animation
- Message thread: user vs. bot bubbles with timestamps
- Typing indicator (three-dot pulse animation) before bot replies
- Inline emoji picker grid toggled from toolbar
- Send on
Enter(Shift+Enter for new line) or send button - Auto-scroll to latest message
- Welcome message on first open
- Support agent header with online status dot
How it works
sendMessage()appends user bubble, shows typing indicator after 600 ms, then appends bot reply after 1–2 s- Bot replies cycle through a predefined array of welcome responses
- Emoji picker is a
<div>grid of Unicode emoji that appends to textarea on click scrollToBottom()called after every DOM append usingscrollTop = scrollHeight