Chat launcher bubble and panel
A floating chat button with unread badge that opens a message panel, with focus handling, Escape to close and a basic send form.
Tailwind CSSFeedback and empty stateschatwidgetlauncherfloating
Live preview
Code
<!-- Floating launcher + panel. Toggle with one button; panel is a region, not a modal -->
<div class="fixed bottom-5 right-5 z-50 flex flex-col items-end gap-3">
<section id="chat-panel" hidden aria-label="Chat with us"
class="flex h-96 w-[min(22rem,calc(100vw-2.5rem))] flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-slate-200">
<header class="flex items-center justify-between bg-indigo-600 px-4 py-3 text-white">
<div>
<p class="font-semibold">Acme support</p>
<p class="text-xs text-indigo-100">We usually reply in a few minutes</p>
</div>
<button type="button" id="chat-close" aria-label="Close chat" class="rounded p-1 hover:bg-indigo-500">×</button>
</header>
<div id="chat-log" class="flex-1 space-y-2 overflow-y-auto p-4 text-sm" aria-live="polite">
<p class="max-w-[80%] rounded-2xl rounded-bl-sm bg-slate-100 px-3 py-2 text-slate-800">Hi! How can we help?</p>
</div>
<form id="chat-form" class="flex gap-2 border-t border-slate-200 p-3">
<label for="chat-input" class="sr-only">Message</label>
<input id="chat-input" autocomplete="off" placeholder="Type a message"
class="min-w-0 flex-1 rounded-full border border-slate-300 px-4 py-2 text-sm focus:border-indigo-500 focus:outline-none">
<button class="rounded-full bg-indigo-600 px-4 text-sm font-medium text-white hover:bg-indigo-500">Send</button>
</form>
</section>
<button type="button" id="chat-launch" aria-expanded="false" aria-controls="chat-panel" aria-label="Open chat"
class="relative grid size-14 place-items-center rounded-full bg-indigo-600 text-white shadow-lg transition hover:scale-105">
<svg viewBox="0 0 24 24" class="size-6 fill-current" aria-hidden="true"><path d="M4 4h16v12H8l-4 4z"/></svg>
<span id="chat-badge" class="absolute -right-1 -top-1 grid size-5 place-items-center rounded-full bg-rose-500 text-[10px] font-bold">1</span>
</button>
</div>
<script>
const panel = document.getElementById('chat-panel');
const launch = document.getElementById('chat-launch');
const log = document.getElementById('chat-log');
function toggle(open) {
panel.hidden = !open;
launch.setAttribute('aria-expanded', String(open));
document.getElementById('chat-badge').hidden = open; // clear the unread dot on open
if (open) document.getElementById('chat-input').focus();
}
launch.addEventListener('click', () => toggle(panel.hidden));
document.getElementById('chat-close').addEventListener('click', () => { toggle(false); launch.focus(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !panel.hidden) toggle(false); });
document.getElementById('chat-form').addEventListener('submit', (e) => {
e.preventDefault();
const input = e.target.elements[0];
if (!input.value.trim()) return;
const p = document.createElement('p');
p.className = 'ml-auto max-w-[80%] rounded-2xl rounded-br-sm bg-indigo-600 px-3 py-2 text-white';
p.textContent = input.value.trim(); // send to your backend here
log.append(p);
input.value = '';
log.scrollTop = log.scrollHeight;
});
</script>
How it works
What it solves
A live chat widget has to sit on top of any page, open quickly and never trap people. This is the front-end shell: launcher, panel and message form, ready to connect to whatever transport you use.
How it works
A fixed container anchors everything to the bottom right. The panel starts hidden and its width is min(22rem, 100vw - 2.5rem), so it never overflows a small phone. The launcher button exposes its state with aria-expanded and aria-controls, which tells assistive technology it controls the panel. A single toggle(open) function handles every transition: show or hide, update the attribute, clear the unread badge and move focus into the input when opening.
Closing with the header button returns focus to the launcher, so keyboard users do not lose their place; Escape does the same from anywhere. The log is an aria-live region, so new agent replies are announced. Outgoing messages are inserted with textContent, which prevents markup injection.
Adapting it
- Replace the append call in the submit handler with a request or socket message.
- Show the badge again when a reply arrives while the panel is closed.
- Change the brand colour classes, and the bubble offsets to avoid cookie banners.
Pitfalls
- Give the container a high enough
z-index, but lower than your cookie banner or modals. - Do not auto-open the panel on load; it is disruptive and harms scores for page experience.
- Remember to load the widget script with
deferon pages with heavy content.
How to use it
- Paste the markup and script before the closing body tag of your layout.
- Connect the submit handler to your message endpoint or socket client.
- Append agent messages to
chat-logwith the incoming bubble classes. - Show the badge and update its count when messages arrive while the panel is closed.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.