Skip to content
UI ComponentChat WidgetOverlayNew

Support Chat Widget with Bot Replies & Human Handoff

A complete customer-support chat widget: launcher with unread badge and a friendly greeting bubble, a chat window that grows from the corner, keyword-based bot replies with typing indicator, quick-reply chips and rich cards, sent / delivered / read ticks, a hand-off to a human agent, emoji picker, image attachments, sound toggle, clear conversation and history saved between visits. Full-screen on phones.

  • Published 23 Oct 2026
  • 1 views
Support chat widget bubble in the corner of a page, with a greeting from a support agent

Overview

Support Chat Widget with Bot Replies & Human Handoff

Support that feels like a conversation — a ready-made chat widget with bot replies, quick replies and a human hand-off.


What's Included

  • Launcher with unread badge and a timed greeting bubble
  • Keyword bot replies with typing indicator, quick-reply chips and rich cards
  • Sent → delivered → read ticks on your messages
  • Hand-off to a human agent with system messages
  • Emoji picker and image attachments
  • Sound toggle (WebAudio blip — no audio file)
  • History saved in localStorage; full-screen on phones

Dependencies

Already linked inside the component's files — no extra setup needed.


Desktop Preview


HTML

html
1<!doctype html>
2<html lang="en">
3  <head>
4    <meta charset="UTF-8" />
5    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6    <title>Support Chat Widget | MetaSkillGroup</title>
7    <link rel="stylesheet" href="style.css" />
8  </head>
9  <body>
10    <!-- created by metaskillgroup-team -->
11
12    <!-- Demo page behind the widget -->
13    <main class="demo">
14      <span class="eyebrow">Chat widget</span>
15      <h1>Support that feels<br /><span>like a conversation.</span></h1>
16      <p>The bubble in the corner is a complete chat widget: quick replies, typing indicator, emoji, image attachments, read receipts, a human hand-off and saved history.</p>
17      <div class="demo-cards"><div></div><div></div><div></div></div>
18    </main>
19
20    <div class="chat-root">
21      <!-- Greeting bubble -->
22      <div class="greeting" id="greeting" hidden>
23        <button type="button" class="greeting-close" aria-label="Dismiss">✕</button>
24        <button type="button" class="greeting-body" id="greeting-open">
25          <span class="g-av">R</span>
26          <span><b>Riya from Nimbus</b>👋 Hi! Need help picking a plan? I'm here.</span>
27        </button>
28      </div>
29
30      <!-- Chat window -->
31      <section class="chat" id="chat" role="dialog" aria-label="Chat with Nimbus support" aria-hidden="true">
32        <header class="chat-head">
33          <div class="team" aria-hidden="true">
34            <span style="--c: #f59e0b">R</span>
35            <span style="--c: #10b981">K</span>
36            <span style="--c: #3b82f6">A</span>
37          </div>
38          <div class="head-text">
39            <b>Nimbus Support</b>
40            <small><i class="online"></i> We usually reply in a few minutes</small>
41          </div>
42          <button type="button" class="head-btn" id="sound-btn" aria-pressed="true" aria-label="Sound on">
43            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 5L6 9H3v6h3l5 4z" /><path class="wave" d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13" /></svg>
44          </button>
45          <div class="more">
46            <button type="button" class="head-btn" id="more-btn" aria-label="More options" aria-expanded="false">
47              <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="5" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="12" cy="19" r="1.5" /></svg>
48            </button>
49            <div class="more-menu" id="more-menu">
50              <button type="button" id="clear-chat">🗑 Clear conversation</button>
51              <button type="button" id="email-transcript">✉️ Email transcript</button>
52            </div>
53          </div>
54          <button type="button" class="head-btn" id="minimize-btn" aria-label="Minimize chat">
55            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
56          </button>
57        </header>
58
59        <div class="messages" id="messages" aria-live="polite"></div>
60
61        <!-- Emoji picker -->
62        <div class="emoji-panel" id="emoji-panel" hidden></div>
63
64        <form class="composer" id="composer">
65          <button type="button" class="c-btn" id="emoji-btn" aria-label="Insert emoji" aria-expanded="false">
66            <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" /><path d="M8.5 14a4 4 0 0 0 7 0M9 9.5h.01M15 9.5h.01" /></svg>
67          </button>
68          <label class="c-btn" aria-label="Attach an image">
69            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.5l-8.5 8.5a6 6 0 0 1-8.5-8.5L12.5 4a4 4 0 0 1 5.7 5.7l-8.5 8.5a2 2 0 0 1-2.8-2.8L15 7.3" /></svg>
70            <input type="file" id="attach" accept="image/*" hidden />
71          </label>
72          <textarea id="input" rows="1" placeholder="Write a message…" aria-label="Message"></textarea>
73          <button type="submit" class="send" id="send" aria-label="Send" disabled>
74            <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M22 2L11 13M22 2l-7 20-4-9-9-4z" /></svg>
75          </button>
76        </form>
77        <p class="powered">Powered by <b>Nimbus</b> · <a href="#">Privacy</a></p>
78      </section>
79
80      <!-- Launcher -->
81      <button type="button" class="launcher" id="launcher" aria-label="Open chat" aria-expanded="false" aria-controls="chat">
82        <svg class="i-chat" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12a8 8 0 0 1-11.6 7.1L4 20.5l1.4-4.9A8 8 0 1 1 21 12z" /></svg>
83        <svg class="i-close" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
84        <span class="unread" id="unread" hidden>1</span>
85      </button>
86    </div>
87
88    <script src="script.js"></script>
89  </body>
90</html>

CSS

css
1/* Importing Google Fonts - Lexend */
2@import url("https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700&display=swap");
3
4* {
5  margin: 0;
6  padding: 0;
7  box-sizing: border-box;
8  font-family: "Lexend", sans-serif;
9}
10
11:root {
12  --page: #f5f6fb;
13  --surface: #ffffff;
14  --text: #191b2a;
15  --muted: #6c7089;
16  --border: #e6e8f1;
17  --brand: #6d5dfc;
18  --brand-2: #b05cff;
19  --bubble-them: #f1f2f8;
20  --ease: cubic-bezier(0.2, 0.8, 0.2, 1);
21}
22
23body {
24  min-height: 100vh;
25  background: var(--page);
26  color: var(--text);
27}
28
29svg {
30  fill: none;
31  stroke: currentColor;
32  stroke-width: 2;
33  stroke-linecap: round;
34  stroke-linejoin: round;
35}
36
37[hidden] {
38  display: none !important;
39}
40
41/* ---------- Demo page ---------- */
42.demo {
43  max-width: 1000px;
44  margin: 0 auto;
45  padding: 90px 24px;
46}
47
48.eyebrow {
49  color: var(--brand);
50  font-size: 0.82rem;
51  font-weight: 600;
52  letter-spacing: 0.14em;
53  text-transform: uppercase;
54}
55
56.demo h1 {
57  margin: 14px 0;
58  font-size: clamp(2.2rem, 5.5vw, 3.8rem);
59  font-weight: 700;
60  line-height: 1.05;
61  letter-spacing: -0.03em;
62}
63
64.demo h1 span {
65  background: linear-gradient(90deg, var(--brand), var(--brand-2));
66  -webkit-background-clip: text;
67  background-clip: text;
68  color: transparent;
69}
70
71.demo p {
72  max-width: 560px;
73  color: var(--muted);
74  font-size: 1.05rem;
75  line-height: 1.65;
76}
77
78.demo-cards {
79  display: grid;
80  grid-template-columns: repeat(3, 1fr);
81  gap: 18px;
82  margin-top: 44px;
83}
84
85.demo-cards div {
86  height: 200px;
87  border: 1px solid var(--border);
88  border-radius: 20px;
89  background: var(--surface);
90}
91
92/* =========================================================
93   Widget
94   ========================================================= */
95.chat-root {
96  position: fixed;
97  right: 24px;
98  bottom: 24px;
99  z-index: 100;
100  display: flex;
101  flex-direction: column;
102  align-items: flex-end;
103  gap: 14px;
104}
105
106/* ---------- Launcher ---------- */
107.launcher {
108  position: relative;
109  display: grid;
110  place-items: center;
111  width: 62px;
112  height: 62px;
113  border: 0;
114  border-radius: 50%;
115  background: linear-gradient(135deg, var(--brand), var(--brand-2));
116  color: #fff;
117  box-shadow: 0 16px 34px -12px rgba(109, 93, 252, 0.8);
118  cursor: pointer;
119  transition: transform 0.3s var(--ease);
120}
121
122.launcher:hover {
123  transform: scale(1.06);
124}
125
126.launcher:focus-visible,
127.head-btn:focus-visible,
128.c-btn:focus-visible,
129.send:focus-visible,
130.chip:focus-visible {
131  outline: 3px solid rgba(109, 93, 252, 0.45);
132  outline-offset: 2px;
133}
134
135.launcher svg {
136  position: absolute;
137  width: 28px;
138  height: 28px;
139  transition: transform 0.35s var(--ease), opacity 0.25s;
140}
141
142.i-close {
143  opacity: 0;
144  transform: rotate(-90deg) scale(0.5);
145}
146
147.launcher[aria-expanded="true"] .i-chat {
148  opacity: 0;
149  transform: rotate(90deg) scale(0.5);
150}
151
152.launcher[aria-expanded="true"] .i-close {
153  opacity: 1;
154  transform: none;
155}
156
157.unread {
158  position: absolute;
159  top: -2px;
160  right: -2px;
161  display: grid;
162  place-items: center;
163  min-width: 22px;
164  height: 22px;
165  padding: 0 6px;
166  border: 3px solid var(--page);
167  border-radius: 999px;
168  background: #ef4444;
169  font-size: 0.72rem;
170  font-weight: 700;
171  animation: popIn 0.4s var(--ease);
172}
173
174@keyframes popIn {
175  from {
176    transform: scale(0);
177  }
178}
179
180/* ---------- Greeting bubble ---------- */
181.greeting {
182  position: relative;
183  max-width: 290px;
184  animation: rise 0.45s var(--ease);
185}
186
187@keyframes rise {
188  from {
189    opacity: 0;
190    transform: translateY(12px) scale(0.95);
191  }
192}
193
194.greeting-body {
195  display: flex;
196  gap: 10px;
197  padding: 14px 16px;
198  border: 1px solid var(--border);
199  border-radius: 18px 18px 4px 18px;
200  background: var(--surface);
201  color: var(--text);
202  font-size: 0.88rem;
203  line-height: 1.45;
204  text-align: left;
205  box-shadow: 0 18px 40px -20px rgba(25, 27, 42, 0.4);
206  cursor: pointer;
207}
208
209.greeting-body b {
210  display: block;
211  font-size: 0.8rem;
212  color: var(--muted);
213  font-weight: 500;
214}
215
216.g-av {
217  display: grid;
218  place-items: center;
219  flex-shrink: 0;
220  width: 32px;
221  height: 32px;
222  border-radius: 50%;
223  background: #f59e0b;
224  color: #fff;
225  font-weight: 700;
226}
227
228.greeting-close {
229  position: absolute;
230  top: -10px;
231  left: -10px;
232  display: grid;
233  place-items: center;
234  width: 24px;
235  height: 24px;
236  border: 1px solid var(--border);
237  border-radius: 50%;
238  background: var(--surface);
239  color: var(--muted);
240  font-size: 0.65rem;
241  cursor: pointer;
242}
243
244/* ---------- Chat window ---------- */
245/* Positioned above the launcher so a closed chat takes no space */
246.chat {
247  position: absolute;
248  right: 0;
249  bottom: 76px;
250  display: flex;
251  flex-direction: column;
252  width: 380px;
253  height: min(620px, calc(100vh - 120px));
254  border: 1px solid var(--border);
255  border-radius: 24px;
256  background: var(--surface);
257  box-shadow: 0 40px 90px -30px rgba(25, 27, 42, 0.45);
258  overflow: hidden;
259  transform-origin: bottom right;
260  opacity: 0;
261  visibility: hidden;
262  transform: translateY(16px) scale(0.92);
263  transition: opacity 0.25s, transform 0.4s var(--ease), visibility 0.25s;
264}
265
266.chat.is-open {
267  opacity: 1;
268  visibility: visible;
269  transform: none;
270}
271
272.chat-head {
273  display: flex;
274  align-items: center;
275  gap: 10px;
276  padding: 16px 14px 16px 18px;
277  background: linear-gradient(135deg, var(--brand), var(--brand-2));
278  color: #fff;
279}
280
281.team {
282  display: flex;
283}
284
285.team span {
286  display: grid;
287  place-items: center;
288  width: 30px;
289  height: 30px;
290  margin-right: -10px;
291  border: 2px solid #8d6dfd;
292  border-radius: 50%;
293  background: var(--c);
294  font-size: 0.8rem;
295  font-weight: 700;
296}
297
298.head-text {
299  flex: 1;
300  min-width: 0;
301  margin-left: 14px;
302  overflow: hidden;
303}
304
305.head-text b {
306  display: block;
307  overflow: hidden;
308  font-size: 0.98rem;
309  font-weight: 600;
310  white-space: nowrap;
311  text-overflow: ellipsis;
312}
313
314.head-text small {
315  display: flex;
316  align-items: center;
317  gap: 6px;
318  color: rgba(255, 255, 255, 0.8);
319  font-size: 0.74rem;
320  font-weight: 300;
321  white-space: nowrap;
322}
323
324.online {
325  width: 8px;
326  height: 8px;
327  border-radius: 50%;
328  background: #4ade80;
329  box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.3);
330}
331
332.head-btn {
333  display: grid;
334  place-items: center;
335  width: 34px;
336  height: 34px;
337  border: 0;
338  border-radius: 10px;
339  background: rgba(255, 255, 255, 0.12);
340  color: #fff;
341  cursor: pointer;
342}
343
344.head-btn:hover {
345  background: rgba(255, 255, 255, 0.22);
346}
347
348.head-btn svg {
349  width: 18px;
350  height: 18px;
351}
352
353.head-btn[aria-pressed="false"] .wave {
354  display: none;
355}
356
357.more {
358  position: relative;
359}
360
361.more-menu {
362  position: absolute;
363  top: calc(100% + 8px);
364  right: 0;
365  z-index: 5;
366  width: 210px;
367  padding: 6px;
368  border: 1px solid var(--border);
369  border-radius: 14px;
370  background: var(--surface);
371  box-shadow: 0 18px 40px -16px rgba(25, 27, 42, 0.4);
372  opacity: 0;
373  visibility: hidden;
374  transform: translateY(-4px);
375  transition: opacity 0.18s, transform 0.2s var(--ease), visibility 0.18s;
376}
377
378.more.is-open .more-menu {
379  opacity: 1;
380  visibility: visible;
381  transform: none;
382}
383
384.more-menu button {
385  display: block;
386  width: 100%;
387  padding: 9px 10px;
388  border: 0;
389  border-radius: 9px;
390  background: none;
391  color: var(--text);
392  font-size: 0.85rem;
393  text-align: left;
394  cursor: pointer;
395}
396
397.more-menu button:hover {
398  background: var(--bubble-them);
399}
400
401/* ---------- Messages ---------- */
402.messages {
403  flex: 1;
404  display: flex;
405  flex-direction: column;
406  gap: 4px;
407  padding: 18px 16px;
408  overflow-y: auto;
409  scroll-behavior: smooth;
410}
411
412.day {
413  align-self: center;
414  margin: 4px 0 10px;
415  padding: 3px 10px;
416  border-radius: 999px;
417  background: var(--bubble-them);
418  color: var(--muted);
419  font-size: 0.7rem;
420}
421
422.system {
423  align-self: center;
424  margin: 8px 0;
425  color: var(--muted);
426  font-size: 0.76rem;
427  text-align: center;
428}
429
430.msg {
431  display: flex;
432  align-items: flex-end;
433  gap: 8px;
434  max-width: 86%;
435  animation: msgIn 0.3s var(--ease);
436}
437
438.no-anim .msg {
439  animation: none;
440}
441
442@keyframes msgIn {
443  from {
444    opacity: 0;
445    transform: translateY(8px);
446  }
447}
448
449.msg.them {
450  align-self: flex-start;
451}
452
453.msg.me {
454  align-self: flex-end;
455  flex-direction: row-reverse;
456}
457
458.m-av {
459  display: grid;
460  place-items: center;
461  flex-shrink: 0;
462  width: 28px;
463  height: 28px;
464  border-radius: 50%;
465  background: var(--c, var(--brand));
466  color: #fff;
467  font-size: 0.72rem;
468  font-weight: 700;
469}
470
471.msg.grouped .m-av {
472  visibility: hidden;
473}
474
475.bubble {
476  padding: 10px 14px;
477  border-radius: 18px;
478  font-size: 0.9rem;
479  line-height: 1.5;
480  overflow-wrap: anywhere;
481}
482
483.them .bubble {
484  border-bottom-left-radius: 6px;
485  background: var(--bubble-them);
486}
487
488.me .bubble {
489  border-bottom-right-radius: 6px;
490  background: linear-gradient(135deg, var(--brand), #8a5cff);
491  color: #fff;
492}
493
494.bubble img {
495  display: block;
496  max-width: 200px;
497  max-height: 200px;
498  margin: -4px -8px;
499  border-radius: 12px;
500}
501
502.meta {
503  display: flex;
504  align-items: center;
505  gap: 4px;
506  margin: 2px 4px 8px;
507  color: #a2a5b9;
508  font-size: 0.68rem;
509}
510
511.meta.me {
512  align-self: flex-end;
513}
514
515.meta.them {
516  margin-left: 40px;
517}
518
519.ticks {
520  letter-spacing: -3px;
521  color: #a2a5b9;
522}
523
524.ticks.read {
525  color: var(--brand);
526}
527
528/* Bot card (e.g. pricing) */
529.card {
530  margin-top: 8px;
531  padding: 12px;
532  border: 1px solid var(--border);
533  border-radius: 14px;
534  background: var(--surface);
535}
536
537.card b {
538  display: block;
539}
540
541.card .price {
542  margin: 2px 0 8px;
543  color: var(--brand);
544  font-weight: 600;
545}
546
547.card a {
548  display: block;
549  padding: 8px;
550  border-radius: 10px;
551  background: var(--text);
552  color: #fff;
553  font-size: 0.82rem;
554  text-align: center;
555  text-decoration: none;
556}
557
558/* Quick replies */
559.chips {
560  display: flex;
561  flex-wrap: wrap;
562  gap: 6px;
563  margin: 2px 0 10px 36px;
564}
565
566.chip {
567  padding: 7px 12px;
568  border: 1.5px solid var(--brand);
569  border-radius: 999px;
570  background: var(--surface);
571  color: var(--brand);
572  font-size: 0.8rem;
573  font-weight: 500;
574  cursor: pointer;
575  transition: background 0.2s, color 0.2s;
576}
577
578.chip:hover {
579  background: var(--brand);
580  color: #fff;
581}
582
583/* Typing indicator */
584.typing .bubble {
585  display: flex;
586  gap: 4px;
587  padding: 14px 16px;
588}
589
590.typing i {
591  width: 7px;
592  height: 7px;
593  border-radius: 50%;
594  background: #9a9db3;
595  animation: blink 1.2s infinite;
596}
597
598.typing i:nth-child(2) {
599  animation-delay: 0.15s;
600}
601
602.typing i:nth-child(3) {
603  animation-delay: 0.3s;
604}
605
606@keyframes blink {
607  0%,
608  60%,
609  100% {
610    opacity: 0.35;
611    transform: translateY(0);
612  }
613  30% {
614    opacity: 1;
615    transform: translateY(-3px);
616  }
617}
618
619/* ---------- Emoji panel ---------- */
620.emoji-panel {
621  display: grid;
622  grid-template-columns: repeat(8, 1fr);
623  gap: 2px;
624  margin: 0 12px;
625  padding: 8px;
626  border: 1px solid var(--border);
627  border-radius: 16px;
628  background: var(--surface);
629  box-shadow: 0 12px 30px -18px rgba(25, 27, 42, 0.4);
630  animation: msgIn 0.2s var(--ease);
631}
632
633.emoji-panel button {
634  aspect-ratio: 1;
635  border: 0;
636  border-radius: 8px;
637  background: none;
638  font-size: 1.25rem;
639  cursor: pointer;
640}
641
642.emoji-panel button:hover {
643  background: var(--bubble-them);
644}
645
646/* ---------- Composer ---------- */
647.composer {
648  display: flex;
649  align-items: flex-end;
650  gap: 4px;
651  margin: 10px 12px 4px;
652  padding: 6px;
653  border: 1px solid var(--border);
654  border-radius: 18px;
655  background: #fafbfe;
656  transition: border-color 0.2s, box-shadow 0.2s;
657}
658
659.composer:focus-within {
660  border-color: var(--brand);
661  box-shadow: 0 0 0 3px rgba(109, 93, 252, 0.12);
662}
663
664.c-btn {
665  display: grid;
666  place-items: center;
667  flex-shrink: 0;
668  width: 36px;
669  height: 36px;
670  border: 0;
671  border-radius: 10px;
672  background: none;
673  color: var(--muted);
674  cursor: pointer;
675}
676
677.c-btn:hover {
678  background: var(--bubble-them);
679  color: var(--text);
680}
681
682.c-btn svg,
683.send svg {
684  width: 20px;
685  height: 20px;
686}
687
688.composer textarea {
689  flex: 1;
690  min-width: 0;
691  max-height: 110px;
692  padding: 8px 4px;
693  border: 0;
694  background: none;
695  color: var(--text);
696  font-size: 0.9rem;
697  line-height: 1.4;
698  resize: none;
699  outline: none;
700}
701
702.send {
703  display: grid;
704  place-items: center;
705  flex-shrink: 0;
706  width: 36px;
707  height: 36px;
708  border: 0;
709  border-radius: 12px;
710  background: var(--brand);
711  color: #fff;
712  cursor: pointer;
713  transition: opacity 0.2s, transform 0.2s;
714}
715
716.send:disabled {
717  opacity: 0.35;
718  cursor: default;
719}
720
721.send:not(:disabled):active {
722  transform: scale(0.9);
723}
724
725.powered {
726  padding: 6px 0 10px;
727  color: #a2a5b9;
728  font-size: 0.68rem;
729  text-align: center;
730}
731
732.powered a {
733  color: inherit;
734}
735
736/* ---------- Phones: full-screen chat ---------- */
737@media (max-width: 520px) {
738  .chat-root {
739    right: 16px;
740    bottom: 16px;
741  }
742
743  .chat {
744    position: fixed;
745    inset: 0;
746    width: auto;
747    height: auto;
748    border-radius: 0;
749    transform-origin: bottom center;
750  }
751
752  .chat-root:has(.chat.is-open) .launcher {
753    display: none;
754  }
755
756  .demo-cards {
757    grid-template-columns: 1fr;
758  }
759}
760
761@media (prefers-reduced-motion: reduce) {
762  *,
763  *::before,
764  *::after {
765    animation: none !important;
766    transition: none !important;
767  }
768}

JavaScript

javascript
1// ---------- Bot brain: first matching rule answers (edit freely) ----------
2const TOPICS = ["💳 Pricing", "📅 Book a demo", "📦 Track my order", "🙋 Talk to a human"];
3const rules = [
4  {
5    match: /price|pricing|plan|cost/i,
6    text: "Plans start at ₹999/month. Most growing teams pick Pro 👇",
7    card: { title: "Pro plan", price: "₹2,499 / month", note: "Unlimited projects · 100 GB · priority support", cta: "Compare all plans" },
8    chips: ["📅 Book a demo", "🙋 Talk to a human"],
9  },
10  { match: /demo|call|meeting/i, text: "Happy to show you around! Pick a slot that suits you:", chips: ["Tomorrow 11:00", "Tomorrow 16:30", "Friday 10:00"] },
11  { match: /^(tomorrow|friday)/i, text: "Booked ✅ A calendar invite is on its way to your inbox." },
12  { match: /human|agent|person|someone/i, handoff: true },
13  { match: /track|order|delivery/i, text: "Sure! Share your order ID — it starts with #NB (try #NB2048)." },
14  { match: /#?nb\d+/i, text: "Found it 📦 Your order is out for delivery and should arrive today by 7 PM." },
15  { match: /\[image\]/i, text: "Thanks for the screenshot! Looking into it 👀" },
16  { match: /thank|thanks|great|awesome|cool/i, text: "Anytime! 💜 Anything else I can help with?" },
17  { match: /^(hi|hello|hey)\b/i, text: "Hey there! 👋 What can I help you with today?", chips: TOPICS },
18];
19const FALLBACK = { text: "Got it — I've shared this with the team and someone will reply here shortly. Anything else meanwhile?", chips: TOPICS };
20const EMOJIS = "😀 😂 🥹 😍 😎 🤔 😅 🙌 👍 👋 🙏 💜 🔥 ✨ 🎉 ✅ ❌ 👀 💡 📦 📅 💳 🚀 ☕".split(" ");
21
22const BOT = { name: "Nimbus Assistant", initial: "N", color: "#6d5dfc" };
23const AGENT = { name: "Riya", initial: "R", color: "#f59e0b" };
24const STORE_KEY = "nimbus-chat";
25
26// ---------- State ----------
27const $ = (id) => document.getElementById(id);
28const chat = $("chat");
29const launcher = $("launcher");
30const list = $("messages");
31const input = $("input");
32const sendBtn = $("send");
33const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
34
35const load = () => {
36  try {
37    return JSON.parse(localStorage.getItem(STORE_KEY)) ?? { messages: [], human: false, sound: true };
38  } catch {
39    return { messages: [], human: false, sound: true };
40  }
41};
42const state = load();
43const save = () => {
44  try {
45    // Images aren't saved (they'd bloat storage) — they come back as a label
46    const messages = state.messages.map((m) => (m.image ? { ...m, image: null, text: "📷 Image" } : m));
47    localStorage.setItem(STORE_KEY, JSON.stringify({ ...state, messages }));
48  } catch {
49    /* storage blocked */
50  }
51};
52
53let unread = 0;
54const isOpen = () => chat.classList.contains("is-open");
55const time = (t) => new Date(t).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
56const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
57
58// ---------- Sound (a tiny blip, no audio file needed) ----------
59let audio;
60const blip = () => {
61  if (!state.sound) return;
62  try {
63    audio ??= new AudioContext();
64    const o = audio.createOscillator();
65    const g = audio.createGain();
66    o.frequency.setValueAtTime(880, audio.currentTime);
67    o.frequency.exponentialRampToValueAtTime(1320, audio.currentTime + 0.08);
68    g.gain.setValueAtTime(0.06, audio.currentTime);
69    g.gain.exponentialRampToValueAtTime(0.0001, audio.currentTime + 0.18);
70    o.connect(g).connect(audio.destination);
71    o.start();
72    o.stop(audio.currentTime + 0.2);
73  } catch {
74    /* audio not available */
75  }
76};
77
78// ---------- Rendering ----------
79const who = (m) => (m.from === "agent" ? AGENT : BOT);
80
81const messageHTML = (m, prev) => {
82  if (m.type === "system") return `<p class="system">${esc(m.text)}</p>`;
83  const grouped = prev && prev.from === m.from && prev.type !== "system";
84  const body = m.image ? `<img src="${m.image}" alt="Attached image" />` : esc(m.text).replace(/\n/g, "<br>");
85  const card = m.card
86    ? `<div class="card"><b>${m.card.title}</b><div class="price">${m.card.price}</div><small>${m.card.note}</small><br><br><a href="#">${m.card.cta}</a></div>`
87    : "";
88  const av = m.from === "me" ? "" : `<span class="m-av" style="--c:${who(m).color}" aria-hidden="true">${who(m).initial}</span>`;
89  const ticks = m.from === "me" ? `<span class="ticks ${m.status === "read" ? "read" : ""}" aria-label="${m.status}">${m.status === "sent" ? "✓" : "✓✓"}</span>` : "";
90  const chips = m.chips && m === state.messages.at(-1) ? `<div class="chips">${m.chips.map((c) => `<button type="button" class="chip">${c}</button>`).join("")}</div>` : "";
91  return `<div class="msg ${m.from === "me" ? "me" : "them"} ${grouped ? "grouped" : ""}" data-id="${m.id}">${av}<div class="bubble">${body}${card}</div></div>
92    <div class="meta ${m.from === "me" ? "me" : "them"}" data-meta="${m.id}">${m.from === "me" ? "" : `${who(m).name} · `}${time(m.time)} ${ticks}</div>${chips}`;
93};
94
95const renderAll = () => {
96  list.classList.add("no-anim");
97  list.innerHTML = `<p class="day">Today</p>${state.messages.map((m, i) => messageHTML(m, state.messages[i - 1])).join("")}`;
98  list.scrollTop = list.scrollHeight;
99  requestAnimationFrame(() => list.classList.remove("no-anim"));
100};
101
102const append = (m) => {
103  list.querySelectorAll(".chips").forEach((c) => c.remove()); // old quick replies go away
104  const prev = state.messages.at(-1);
105  state.messages.push(m);
106  list.insertAdjacentHTML("beforeend", messageHTML(m, prev));
107  list.scrollTop = list.scrollHeight;
108  save();
109  if (m.from !== "me" && m.type !== "system") {
110    blip();
111    if (!isOpen()) setUnread(unread + 1);
112  }
113};
114
115const setStatus = (m, status) => {
116  m.status = status;
117  const meta = list.querySelector(`[data-meta="${m.id}"] .ticks`);
118  if (meta) {
119    meta.textContent = status === "sent" ? "✓" : "✓✓";
120    meta.classList.toggle("read", status === "read");
121    meta.setAttribute("aria-label", status);
122  }
123  save();
124};
125
126const setUnread = (n) => {
127  unread = n;
128  $("unread").textContent = n;
129  $("unread").hidden = n === 0;
130  launcher.setAttribute("aria-label", n ? `Open chat, ${n} unread` : "Open chat");
131};
132
133// ---------- Typing indicator + replies ----------
134const showTyping = (person) => {
135  list.insertAdjacentHTML(
136    "beforeend",
137    `<div class="msg them typing" id="typing"><span class="m-av" style="--c:${person.color}">${person.initial}</span><div class="bubble"><i></i><i></i><i></i></div></div>`,
138  );
139  list.scrollTop = list.scrollHeight;
140};
141const hideTyping = () => $("typing")?.remove();
142const wait = (ms) => new Promise((r) => setTimeout(r, ms));
143
144let busy = Promise.resolve();
145const reply = (userMsg) => {
146  busy = busy.then(async () => {
147    await wait(500);
148    setStatus(userMsg, "read");
149    const text = userMsg.image ? "[image]" : userMsg.text;
150    const rule = rules.find((r) => r.match.test(text.replace(/^[^\w#\[]+/, ""))) ?? FALLBACK;
151
152    if (rule.handoff && !state.human) {
153      append({ id: Date.now(), type: "system", text: "Connecting you to a human…", time: Date.now() });
154      showTyping(AGENT);
155      await wait(1800);
156      hideTyping();
157      state.human = true;
158      append({ id: Date.now(), type: "system", text: "Riya joined the conversation", time: Date.now() });
159      append({ id: Date.now() + 1, from: "agent", text: "Hi, I'm Riya 👋 I've read the chat so far — how can I help?", time: Date.now() });
160      return;
161    }
162
163    const person = state.human ? AGENT : BOT;
164    showTyping(person);
165    await wait(700 + Math.min((rule.text ?? "").length * 18, 1600));
166    hideTyping();
167    append({ id: Date.now(), from: state.human ? "agent" : "bot", text: rule.text, card: rule.card, chips: rule.chips, time: Date.now() });
168  });
169};
170
171const send = ({ text, image }) => {
172  const m = { id: Date.now(), from: "me", text, image, time: Date.now(), status: "sent" };
173  append(m);
174  setTimeout(() => setStatus(m, m.status === "read" ? "read" : "delivered"), 350);
175  reply(m);
176};
177
178// ---------- Open / close ----------
179const welcome = () => {
180  if (state.messages.length) return;
181  append({ id: Date.now(), from: "bot", text: "Hi! I'm the Nimbus assistant 🤖 Ask me anything, or pick a topic:", chips: TOPICS, time: Date.now() });
182};
183
184const setOpen = (open) => {
185  chat.classList.toggle("is-open", open);
186  chat.setAttribute("aria-hidden", !open);
187  launcher.setAttribute("aria-expanded", open);
188  $("greeting").hidden = true;
189  if (open) {
190    setUnread(0);
191    welcome();
192    list.scrollTop = list.scrollHeight;
193    setTimeout(() => input.focus(), reduceMotion ? 0 : 250);
194  } else {
195    launcher.focus();
196  }
197};
198
199launcher.addEventListener("click", () => setOpen(!isOpen()));
200$("minimize-btn").addEventListener("click", () => setOpen(false));
201$("greeting-open").addEventListener("click", () => setOpen(true));
202$("greeting").querySelector(".greeting-close").addEventListener("click", () => {
203  $("greeting").hidden = true;
204  setUnread(0);
205  sessionStorage.setItem("nimbus-greeted", "1");
206});
207
208// ---------- Composer ----------
209const autoGrow = () => {
210  input.style.height = "auto";
211  input.style.height = `${input.scrollHeight}px`;
212  sendBtn.disabled = !input.value.trim();
213};
214input.addEventListener("input", autoGrow);
215input.addEventListener("keydown", (e) => {
216  // Enter sends, Shift+Enter adds a new line
217  if (e.key === "Enter" && !e.shiftKey) {
218    e.preventDefault();
219    $("composer").requestSubmit();
220  }
221});
222$("composer").addEventListener("submit", (e) => {
223  e.preventDefault();
224  const text = input.value.trim();
225  if (!text) return;
226  input.value = "";
227  autoGrow();
228  $("emoji-panel").hidden = true;
229  send({ text });
230});
231
232list.addEventListener("click", (e) => {
233  const chip = e.target.closest(".chip");
234  if (chip) send({ text: chip.textContent });
235});
236
237// Emoji picker
238const emojiPanel = $("emoji-panel");
239emojiPanel.innerHTML = EMOJIS.map((e) => `<button type="button" aria-label="${e}">${e}</button>`).join("");
240$("emoji-btn").addEventListener("click", (e) => {
241  emojiPanel.hidden = !emojiPanel.hidden;
242  e.currentTarget.setAttribute("aria-expanded", !emojiPanel.hidden);
243});
244emojiPanel.addEventListener("click", (e) => {
245  const b = e.target.closest("button");
246  if (!b) return;
247  const pos = input.selectionStart ?? input.value.length;
248  input.value = input.value.slice(0, pos) + b.textContent + input.value.slice(pos);
249  input.focus();
250  input.selectionStart = input.selectionEnd = pos + b.textContent.length;
251  autoGrow();
252});
253
254// Image attachment
255$("attach").addEventListener("change", (e) => {
256  const file = e.target.files[0];
257  e.target.value = "";
258  if (!file || !file.type.startsWith("image/")) return;
259  if (file.size > 5 * 1024 * 1024) return append({ id: Date.now(), type: "system", text: "Images must be under 5 MB", time: Date.now() });
260  const reader = new FileReader();
261  reader.onload = () => send({ image: reader.result, text: "" });
262  reader.readAsDataURL(file);
263});
264
265// Header: sound + more menu
266const soundBtn = $("sound-btn");
267const syncSound = () => {
268  soundBtn.setAttribute("aria-pressed", state.sound);
269  soundBtn.setAttribute("aria-label", state.sound ? "Sound on" : "Sound off");
270};
271soundBtn.addEventListener("click", () => {
272  state.sound = !state.sound;
273  syncSound();
274  save();
275});
276
277const more = document.querySelector(".more");
278$("more-btn").addEventListener("click", (e) => {
279  e.stopPropagation();
280  more.classList.toggle("is-open");
281  e.currentTarget.setAttribute("aria-expanded", more.classList.contains("is-open"));
282});
283document.addEventListener("click", () => more.classList.remove("is-open"));
284$("clear-chat").addEventListener("click", () => {
285  state.messages = [];
286  state.human = false;
287  save();
288  renderAll();
289  welcome();
290});
291$("email-transcript").addEventListener("click", () =>
292  append({ id: Date.now(), type: "system", text: "Transcript sent to your email ✉️", time: Date.now() }),
293);
294
295document.addEventListener("keydown", (e) => {
296  if (e.key === "Escape" && isOpen()) setOpen(false);
297});
298
299// ---------- Init ----------
300syncSound();
301renderAll();
302// A friendly nudge after a few seconds (once per visit)
303setTimeout(() => {
304  if (isOpen() || sessionStorage.getItem("nimbus-greeted")) return;
305  $("greeting").hidden = false;
306  setUnread(1);
307  sessionStorage.setItem("nimbus-greeted", "1");
308}, 2500);

Built by MetaSkillGroup — copy it, use it, make it yours.

Features

  • Launcher with unread badge and a timed greeting bubble

  • Keyword bot replies with typing indicator, quick-reply chips and rich cards

  • Sent → delivered → read ticks on your messages

  • Hand-off to a human agent with system messages

  • Emoji picker and image attachments

  • Sound toggle (WebAudio blip — no audio file)

  • History saved in localStorage; full-screen on phones

Built With

🌐

HTML5

🎨

CSS3

⚡

JavaScript

Compatibility

Browsers
ChromeEdgeFirefoxSafariOperaBraveArcVivaldi
Devices
DesktopTabletMobile
Frameworks
Vanilla JSHTML/CSS

❓ FAQs

Edit the rules array in script.js. Each rule has a regular expression to match, the reply text and optional quick-reply chips or a card. The first match wins.

Yes. Replace the body of reply() with a fetch() to your API (or an AI model) and append the response with append({ from: 'bot', text }).

In localStorage under nimbus-chat. Images are not stored (they'd fill storage), so they come back as a '📷 Image' label.

MetaSkillGroup

Written by

MetaSkillGroup

October 23, 2026

Guides from our blog

Need it built for you?

Built to the same standard as our client work.

If nothing here fits exactly, tell us what you need — our core developers build it. Requirements call first, a written SRS before any code, and a price agreed up front.