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
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
- Google Fonts — Lexend https://fonts.googleapis.com/css2?family=Lexend:wght@300;400;500;600;700&display=swap
Already linked inside the component's files — no extra setup needed.
Desktop Preview
HTML
html1<!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
css1/* 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
javascript1// ---------- 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
❓ 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.

Written by
MetaSkillGroup
Guides from our blog
Frontend Developer Roadmap 2026: A Step-by-Step Guide for BeginnersA simple, step-by-step frontend developer roadmap for 2026. Learn HTML, CSS, JavaScript, Git, React, TypeScript and Next.js in the right order, with a 6-month study plan, beginner projects, free resources and a checklist for your first frontend internship.
MERN Stack Roadmap for Beginners 2026: A Step-by-Step Guide to Becoming a Full-Stack DeveloperConfused about where to start with MERN? This step-by-step MERN stack roadmap for 2026 takes you from zero to your first full-stack project. Learn JavaScript, Node.js, Express, MongoDB and React in the right order, with a clear timeline, milestone projects, common mistakes and tips to learn faster with AI tools.
Top 7 React Optimization Techniques to Boost Your App Performance in 2026.Learn the top 7 React optimization techniques including Lazy Loading, Memoization, Debouncing, Throttling, Virtualization, Code Splitting, and Caching to build blazing fast React apps in 2026.
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.