Open demo

CSS code

HTML
<div><span class="lbl">Conversation with a code answer, copy, regenerate and feedback</span>
<div class="ai" data-assistant>
  <div class="ai-head">
    <span class="ai-mark" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span>
    <h2 id="ai-title-a">Quill</h2>
    <p>Writing and code helper</p>
  </div>
  <div class="ai-log" role="log" aria-live="polite" aria-labelledby="ai-title-a" tabindex="0" data-log>
    <div class="ai-user"><p class="sr-only">You, <time datetime="2026-10-14T10:02">10:02</time></p><p class="ai-bubble">How do I debounce a search input in plain JavaScript?</p></div>
    <div class="ai-bot" data-bot>
      <span class="ai-mark" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span>
      <div>
        <p class="ai-who">Quill <time datetime="2026-10-14T10:02">10:02</time></p>
        <p class="ai-thinking" data-thinking hidden><span class="ai-dots" aria-hidden="true"><i></i><i></i><i></i></span>Quill is thinking</p>
        <div class="ai-answer" data-answer>
          <p>Wrap the handler so it only runs after the user stops typing. Every keystroke clears the previous timer and starts a new one:</p>
          <div class="ai-code" data-code>
            <div class="ai-code-bar"><span>JavaScript</span><button class="ai-copy" type="button" data-copy><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg><span data-copy-label>Copy</span><span class="sr-only"> code</span></button></div>
<pre tabindex="0" aria-label="JavaScript code"><code><span class="k">function</span> <span class="f">debounce</span>(fn, wait = 250) {
  <span class="k">let</span> timer;
  <span class="k">return</span> (...args) => {
    <span class="f">clearTimeout</span>(timer);
    timer = <span class="f">setTimeout</span>(() => fn(...args), wait);
  };
}

search.<span class="f">addEventListener</span>(<span class="s">'input'</span>, <span class="f">debounce</span>(e => {
  <span class="f">fetchResults</span>(e.target.value);
}));</code></pre>
          </div>
          <p>A wait of 200 to 300 ms still feels instant and cuts most of the requests. Use <code>wait = 0</code> to turn it off while testing.</p>
        </div>
        <div class="ai-actions" role="group" aria-label="Response actions">
          <button class="ai-act" type="button" data-regen><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg>Regenerate</button>
          <button class="ai-act icon" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 11v9H4v-9zM7 11l4-8a2.5 2.5 0 0 1 2.5 2.5V9h5.2a2 2 0 0 1 2 2.3l-1.3 7A2 2 0 0 1 17.4 20H7"/></svg><span class="sr-only">Good response</span></button>
          <button class="ai-act icon" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 13V4h3v9zM17 13l-4 8a2.5 2.5 0 0 1-2.5-2.5V15H5.3a2 2 0 0 1-2-2.3l1.3-7A2 2 0 0 1 6.6 4H17"/></svg><span class="sr-only">Bad response</span></button>
        </div>
      </div>
    </div>
  </div>
  <div class="ai-prompts" role="group" aria-label="Suggested prompts">
    <button class="ai-chip" type="button" data-prompt>Shorten my workshop invite</button>
    <button class="ai-chip" type="button" data-prompt>Suggest three subject lines</button>
    <button class="ai-chip" type="button" data-prompt>Write a polite invoice reminder</button>
  </div>
  <form class="ai-composer" data-composer>
    <label class="sr-only" for="ai-input-a">Message Quill</label>
    <textarea id="ai-input-a" rows="1" placeholder="Ask Quill anything" data-input></textarea>
    <button class="ai-send" type="submit"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg><span class="sr-only">Send</span></button>
  </form>
  <p class="ai-note">Quill can make mistakes. Check important details.</p>
  <p class="sr-only" role="status" data-status></p>
  <template data-tpl="user"><div class="ai-user"><p class="sr-only">You, <time data-time></time></p><p class="ai-bubble" data-text></p></div></template>
  <template data-tpl="bot"><div class="ai-bot" data-bot><span class="ai-mark" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span><div><p class="ai-who">Quill <time data-time></time></p><p class="ai-thinking" data-thinking hidden><span class="ai-dots" aria-hidden="true"><i></i><i></i><i></i></span>Quill is thinking</p><div class="ai-answer" data-answer></div><div class="ai-actions" role="group" aria-label="Response actions"><button class="ai-act" type="button" data-regen><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg>Regenerate</button><button class="ai-act icon" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 11v9H4v-9zM7 11l4-8a2.5 2.5 0 0 1 2.5 2.5V9h5.2a2 2 0 0 1 2 2.3l-1.3 7A2 2 0 0 1 17.4 20H7"/></svg><span class="sr-only">Good response</span></button><button class="ai-act icon" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 13V4h3v9zM17 13l-4 8a2.5 2.5 0 0 1-2.5-2.5V15H5.3a2 2 0 0 1-2-2.3l1.3-7A2 2 0 0 1 6.6 4H17"/></svg><span class="sr-only">Bad response</span></button></div></div></div></template>
</div></div>

<div><span class="lbl">New chat with suggested prompts</span>
<div class="ai fresh" data-assistant>
  <div class="ai-head">
    <span class="ai-mark" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span>
    <h2 id="ai-title-b">Quill</h2>
    <p>New chat</p>
  </div>
  <div class="ai-log" role="log" aria-live="polite" aria-labelledby="ai-title-b" tabindex="0" data-log>
    <div class="ai-empty" data-empty>
      <h3>Good morning, Sam. What are we writing today?</h3>
      <div class="ai-cards" role="group" aria-label="Suggested prompts">
        <button class="ai-card" type="button" data-prompt><b>Shorten my workshop invite</b><span>Keep the date, time and price</span></button>
        <button class="ai-card" type="button" data-prompt><b>Suggest three subject lines</b><span>For the April newsletter</span></button>
        <button class="ai-card" type="button" data-prompt><b>Write a polite invoice reminder</b><span>Friendly, under 60 words</span></button>
        <button class="ai-card" type="button" data-prompt><b>Explain CSS container queries</b><span>In two short paragraphs</span></button>
      </div>
    </div>
  </div>
  <form class="ai-composer" data-composer>
    <label class="sr-only" for="ai-input-b">Message Quill</label>
    <textarea id="ai-input-b" rows="1" placeholder="Ask Quill anything" data-input></textarea>
    <button class="ai-send" type="submit"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg><span class="sr-only">Send</span></button>
  </form>
  <p class="ai-note">Quill can make mistakes. Check important details.</p>
  <p class="sr-only" role="status" data-status></p>
  <template data-tpl="user"><div class="ai-user"><p class="sr-only">You, <time data-time></time></p><p class="ai-bubble" data-text></p></div></template>
  <template data-tpl="bot"><div class="ai-bot" data-bot><span class="ai-mark" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span><div><p class="ai-who">Quill <time data-time></time></p><p class="ai-thinking" data-thinking hidden><span class="ai-dots" aria-hidden="true"><i></i><i></i><i></i></span>Quill is thinking</p><div class="ai-answer" data-answer></div><div class="ai-actions" role="group" aria-label="Response actions"><button class="ai-act" type="button" data-regen><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg>Regenerate</button><button class="ai-act icon" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 11v9H4v-9zM7 11l4-8a2.5 2.5 0 0 1 2.5 2.5V9h5.2a2 2 0 0 1 2 2.3l-1.3 7A2 2 0 0 1 17.4 20H7"/></svg><span class="sr-only">Good response</span></button><button class="ai-act icon" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 13V4h3v9zM17 13l-4 8a2.5 2.5 0 0 1-2.5-2.5V15H5.3a2 2 0 0 1-2-2.3l1.3-7A2 2 0 0 1 6.6 4H17"/></svg><span class="sr-only">Bad response</span></button></div></div></div></template>
</div></div>
CSS
/* AI assistant chat: a role="log" conversation whose replies stream in word by word (all at once under reduced motion).
   Replies are canned demo text; nothing is sent anywhere. */
.ai{--ai-accent:#b4451f;--ai-accent-dark:#923617;--ai-soft:#f8e4da;--ai-user:#efe9df;--ai-ink:#22201c;--ai-muted:#5f5a52;--ai-line:#e6dfd3;--ai-bg:#fffdf9;
  --ai-code-bg:#1f1d1a;--ai-code-ink:#f1ece4;--ai-serif:ui-serif,Georgia,"Times New Roman",serif;--ai-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
  display:flex;flex-direction:column;height:800px;overflow:hidden;background:var(--ai-bg);border:1px solid var(--ai-line);border-radius:22px;
  color:var(--ai-ink);font-size:.95rem;line-height:1.55;box-shadow:0 24px 50px -30px rgba(60,40,20,.35)}
.ai.fresh{height:560px}
.ai :where(h2,h3,p,pre){margin:0}
:where(.ai) button{font:inherit;color:inherit;cursor:pointer}
.ai .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.ai :focus-visible{outline:2px solid var(--ai-accent);outline-offset:2px}

.ai-head{display:flex;align-items:center;gap:10px;padding:14px 20px;border-bottom:1px solid var(--ai-line)}
.ai-mark{flex:none;display:grid;place-items:center;width:30px;height:30px;border-radius:9px;background:var(--ai-accent);color:#fff}
.ai-head h2{font-size:1rem;font-weight:700}
.ai-head p{margin-left:auto;font-size:.78rem;color:var(--ai-muted)}

/* log */
.ai-log{flex:1;display:flex;flex-direction:column;gap:22px;overflow-y:auto;padding:24px 22px}
.ai-log:focus-visible{outline-offset:-3px}
.ai-user{align-self:flex-end;max-width:80%}
.ai-user .ai-bubble{padding:10px 15px;border-radius:18px 18px 6px 18px;background:var(--ai-user);white-space:pre-wrap;overflow-wrap:anywhere}
.ai-bot{display:grid;grid-template-columns:30px minmax(0,1fr);gap:12px}
.ai-who{display:flex;align-items:baseline;gap:8px;margin-bottom:4px;font-size:.84rem;font-weight:700}
.ai-who time,.ai-user time{font-size:.74rem;font-weight:400;color:var(--ai-muted)}
.ai-answer{display:flex;flex-direction:column;gap:10px}
.ai-answer p code{padding:1px 5px;border-radius:5px;background:var(--ai-user);font-family:var(--ai-mono);font-size:.86em}

/* code block with a copy button */
.ai-code{overflow:hidden;border-radius:14px;background:var(--ai-code-bg);color:var(--ai-code-ink)}
.ai-code-bar{display:flex;align-items:center;justify-content:space-between;padding:6px 8px 6px 14px;border-bottom:1px solid #35322d;font-size:.76rem;color:#c9c2b6}
.ai-copy{display:flex;align-items:center;gap:6px;padding:5px 10px;border:0;border-radius:8px;background:none;color:#f1ece4;font-size:.78rem}
.ai-copy:hover{background:#35322d}
.ai-code :focus-visible{outline-color:#f4a77f}
.ai-code pre{overflow-x:auto;padding:14px 16px;font-family:var(--ai-mono);font-size:.82rem;line-height:1.6;tab-size:2}
.ai-code pre:focus-visible{outline-offset:-3px}
.ai-code .k{color:#f4a77f}.ai-code .s{color:#a8d8a0}.ai-code .c{color:#a39d93}.ai-code .f{color:#9ccfff}

/* thinking indicator; the dots hold still under reduced motion */
.ai-thinking{display:flex;align-items:center;gap:8px;font-size:.86rem;color:var(--ai-muted)}
.ai-thinking[hidden]{display:none}
.ai-dots{display:flex;gap:4px}
.ai-dots i{width:7px;height:7px;border-radius:50%;background:var(--ai-accent);animation:ai-pulse 1.1s infinite ease-in-out}
.ai-dots i:nth-child(2){animation-delay:.18s}.ai-dots i:nth-child(3){animation-delay:.36s}
@keyframes ai-pulse{0%,80%,100%{opacity:.3;transform:scale(.8)}40%{opacity:1;transform:none}}

/* response actions */
.ai-actions{display:flex;flex-wrap:wrap;align-items:center;gap:4px;margin-top:8px}
.ai-act{display:flex;align-items:center;gap:6px;height:32px;padding:0 10px;border:1px solid transparent;border-radius:9px;background:none;font-size:.8rem;color:var(--ai-muted)}
.ai-act.icon{width:32px;padding:0;justify-content:center}
.ai-act:hover{background:var(--ai-user);color:var(--ai-ink)}
.ai-act[aria-pressed="true"]{border-color:var(--ai-accent);background:var(--ai-soft);color:var(--ai-accent-dark)}
.ai-act[aria-pressed="true"] svg{fill:currentColor}
.ai-act:disabled{opacity:.6;cursor:not-allowed}

/* suggested prompts and empty state */
.ai-prompts{display:flex;flex-wrap:wrap;gap:8px;padding:0 20px 12px}
.ai-chip{padding:7px 13px;border:1px solid #cfc6b8;border-radius:999px;background:var(--ai-bg);font-size:.84rem}
.ai-chip:hover{border-color:var(--ai-accent);background:var(--ai-soft)}
.ai-empty{flex:1;display:flex;flex-direction:column;justify-content:center;gap:22px;padding:4px 2px}
.ai-empty h3{font-family:var(--ai-serif);font-size:clamp(1.5rem,4vw,2rem);font-weight:500;line-height:1.2;letter-spacing:-.01em}
.ai-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:10px}
.ai-card{display:flex;flex-direction:column;gap:2px;padding:14px 16px;border:1px solid var(--ai-line);border-radius:14px;background:#fff;text-align:left;transition:border-color .15s,transform .15s}
.ai-card:hover{border-color:var(--ai-accent);transform:translateY(-2px)}
.ai-card b{font-size:.9rem}
.ai-card span{font-size:.82rem;color:var(--ai-muted)}

/* composer */
.ai-composer{margin:0 16px 10px;padding:8px 8px 8px 16px;display:flex;align-items:flex-end;gap:8px;border:1.5px solid #9c9385;border-radius:20px;background:#fff}
.ai-composer:focus-within{border-color:var(--ai-accent);box-shadow:0 0 0 3px var(--ai-soft)}
.ai-composer textarea{box-sizing:border-box;flex:1;min-width:0;max-height:160px;padding:7px 0;border:0;background:none;color:var(--ai-ink);font:inherit;line-height:1.45;resize:none}
.ai-composer textarea:focus-visible{outline:none}
.ai-composer textarea::placeholder{color:#6d675e}
.ai-send{flex:none;display:grid;place-items:center;width:38px;height:38px;border:0;border-radius:12px;background:var(--ai-accent);color:#fff}
.ai-send:hover{background:var(--ai-accent-dark)}
.ai-send:disabled{background:#e3ddd2;color:#5f5a52;cursor:not-allowed}
.ai-note{padding:0 20px 14px;font-size:.74rem;color:var(--ai-muted);text-align:center}
@media (max-width:560px){.ai,.ai.fresh{height:680px}.ai-log{padding:18px 14px}.ai-prompts{flex-wrap:nowrap;overflow-x:auto}.ai-chip{flex:none}.ai-bot{grid-template-columns:minmax(0,1fr)}.ai-bot>.ai-mark{display:none}.ai-composer{margin:0 10px 10px}.ai-prompts{padding:0 12px 10px}}
@media (prefers-reduced-motion:reduce){.ai-dots i{animation:none;opacity:.7}.ai-card{transition:none}.ai-card:hover{transform:none}}
JavaScript
/* AI assistant demo: sends append your message, then a canned reply streams in word by word (instantly under reduced motion).
   Copy puts the code on the clipboard, Regenerate streams an alternative, and the two vote buttons are a pressed/unpressed pair.
   Replace the replies list with a call to your own model. */
document.querySelectorAll('[data-assistant]').forEach(function (app) {
  var log = app.querySelector('[data-log]'), form = app.querySelector('[data-composer]'), input = app.querySelector('[data-input]');
  var send = form.querySelector('[type="submit"]'), status = app.querySelector('[data-status]');
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)'), busy = false, turn = 0;
  var pad = function (n) { return String(n).padStart(2, '0'); };
  var replies = [
    'Our spring workshop runs on 12 April from 10:00 to 16:00 at Harbour Studio. Places are limited to 18, lunch and clay are included, and a seat costs 65 euros.',
    'Three to test: "Your spring workshop seat is waiting", "18 places, one Saturday, all the clay you need" and "Last call for April at Harbour Studio".',
    'Hi Jonas, just checking whether you had a chance to look at the September invoice. Happy to resend it or split the payment if that helps. Thanks, Sam',
    'Container queries let a component respond to the width of its parent instead of the window. Mark the parent with container-type: inline-size, then write @container (min-width: 400px) rules for the child.'
  ];
  var alt = 'Another way: listen for the input event, keep the timeout id in a variable and clear it on every keystroke. When the timeout finally fires, search with the current value. 250 ms is a good first delay.';

  function stamp(t) { var d = new Date(); t.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes()); t.dateTime = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + t.textContent; }
  function add(kind) {
    var node = app.querySelector('template[data-tpl="' + kind + '"]').content.firstElementChild.cloneNode(true);
    stamp(node.querySelector('[data-time]'));
    log.appendChild(node); log.scrollTop = log.scrollHeight;
    return node;
  }
  function setBusy(on) {
    busy = on;
    if (on) log.setAttribute('aria-busy', 'true'); else log.removeAttribute('aria-busy');
    app.querySelectorAll('[data-regen]').forEach(function (b) { b.disabled = on; });
    grow();
  }
  function stream(bot, text) {
    var answer = bot.querySelector('[data-answer]'), thinking = bot.querySelector('[data-thinking]');
    setBusy(true); answer.textContent = ''; thinking.hidden = false;
    setTimeout(function () {
      thinking.hidden = true;
      var p = document.createElement('p'); answer.appendChild(p);
      if (reduce.matches) { p.textContent = text; log.scrollTop = log.scrollHeight; return setBusy(false); }
      var words = text.split(' '), i = 0;
      var timer = setInterval(function () {
        p.textContent += (i ? ' ' : '') + words[i++];
        log.scrollTop = log.scrollHeight;
        if (i >= words.length) { clearInterval(timer); setBusy(false); }
      }, 45);
    }, reduce.matches ? 250 : 900);
  }
  function ask(text) {
    if (busy || !text) return;
    var empty = log.querySelector('[data-empty]');
    if (empty) empty.remove();
    add('user').querySelector('[data-text]').textContent = text;
    stream(add('bot'), replies[turn++ % replies.length]);
  }

  function grow() { input.style.height = 'auto'; input.style.height = input.scrollHeight + 'px'; send.disabled = busy || !input.value.trim(); }
  input.addEventListener('input', grow);
  input.addEventListener('keydown', function (e) {
    if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); }
  });
  form.addEventListener('submit', function (e) {
    e.preventDefault();
    var text = input.value.trim();
    if (!text || busy) return;
    input.value = ''; ask(text); input.focus();
  });

  app.addEventListener('click', function (e) {
    var b = e.target.closest('button');
    if (!b) return;
    if (b.hasAttribute('data-prompt')) { ask((b.querySelector('b') || b).textContent); input.focus(); }
    if (b.hasAttribute('data-regen')) stream(b.closest('[data-bot]'), alt);
    if (b.hasAttribute('data-vote')) {
      var on = b.getAttribute('aria-pressed') !== 'true';
      b.parentNode.querySelectorAll('[data-vote]').forEach(function (v) { v.setAttribute('aria-pressed', 'false'); });
      b.setAttribute('aria-pressed', String(on));
      status.textContent = on ? 'Thanks for the feedback' : '';
    }
    if (b.hasAttribute('data-copy')) {
      var code = b.closest('[data-code]').querySelector('code').textContent, label = b.querySelector('[data-copy-label]');
      var done = function () { label.textContent = 'Copied'; status.textContent = 'Code copied'; setTimeout(function () { label.textContent = 'Copy'; }, 2000); };
      if (navigator.clipboard) navigator.clipboard.writeText(code).then(done, function () { status.textContent = 'Copy failed, select the code instead'; });
      else status.textContent = 'Copy is not available, select the code instead';
    }
  });
  log.scrollTop = log.scrollHeight;
  grow();
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Conversation with a code answer, copy, regenerate and feedback</span>
<div class="card ai" data-assistant>
  <div class="card-header ai-head d-flex align-items-center gap-2">
    <span class="ai-mark" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span>
    <h2 class="fw-bold" id="ai-title-a">Quill</h2>
    <p>Writing and code helper</p>
  </div>
  <div class="ai-log" role="log" aria-live="polite" aria-labelledby="ai-title-a" tabindex="0" data-log>
    <div class="ai-user"><p class="visually-hidden">You, <time datetime="2026-10-14T10:02">10:02</time></p><p class="ai-bubble">How do I debounce a search input in plain JavaScript?</p></div>
    <div class="ai-bot" data-bot>
      <span class="ai-mark" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span>
      <div>
        <p class="ai-who">Quill <time datetime="2026-10-14T10:02">10:02</time></p>
        <p class="ai-thinking" data-thinking hidden><span class="ai-dots" aria-hidden="true"><i></i><i></i><i></i></span>Quill is thinking</p>
        <div class="ai-answer" data-answer>
          <p>Wrap the handler so it only runs after the user stops typing. Every keystroke clears the previous timer and starts a new one:</p>
          <div class="ai-code" data-code>
            <div class="ai-code-bar"><span>JavaScript</span><button class="btn btn-sm ai-copy d-flex align-items-center gap-2" type="button" data-copy><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg><span data-copy-label>Copy</span><span class="visually-hidden"> code</span></button></div>
<pre tabindex="0" aria-label="JavaScript code"><code><span class="k">function</span> <span class="f">debounce</span>(fn, wait = 250) {
  <span class="k">let</span> timer;
  <span class="k">return</span> (...args) => {
    <span class="f">clearTimeout</span>(timer);
    timer = <span class="f">setTimeout</span>(() => fn(...args), wait);
  };
}

search.<span class="f">addEventListener</span>(<span class="s">'input'</span>, <span class="f">debounce</span>(e => {
  <span class="f">fetchResults</span>(e.target.value);
}));</code></pre>
          </div>
          <p>A wait of 200 to 300 ms still feels instant and cuts most of the requests. Use <code>wait = 0</code> to turn it off while testing.</p>
        </div>
        <div class="ai-actions d-flex flex-wrap align-items-center gap-1 mt-2" role="group" aria-label="Response actions">
          <button class="btn ai-act d-flex align-items-center gap-2" type="button" data-regen><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg>Regenerate</button>
          <button class="btn ai-act icon" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 11v9H4v-9zM7 11l4-8a2.5 2.5 0 0 1 2.5 2.5V9h5.2a2 2 0 0 1 2 2.3l-1.3 7A2 2 0 0 1 17.4 20H7"/></svg><span class="visually-hidden">Good response</span></button>
          <button class="btn ai-act icon" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 13V4h3v9zM17 13l-4 8a2.5 2.5 0 0 1-2.5-2.5V15H5.3a2 2 0 0 1-2-2.3l1.3-7A2 2 0 0 1 6.6 4H17"/></svg><span class="visually-hidden">Bad response</span></button>
        </div>
      </div>
    </div>
  </div>
  <div class="ai-prompts d-flex gap-2" role="group" aria-label="Suggested prompts">
    <button class="btn rounded-pill ai-chip" type="button" data-prompt>Shorten my workshop invite</button>
    <button class="btn rounded-pill ai-chip" type="button" data-prompt>Suggest three subject lines</button>
    <button class="btn rounded-pill ai-chip" type="button" data-prompt>Write a polite invoice reminder</button>
  </div>
  <form class="ai-composer d-flex align-items-end gap-2" data-composer>
    <label class="visually-hidden" for="ai-input-a">Message Quill</label>
    <textarea class="form-control" id="ai-input-a" rows="1" placeholder="Ask Quill anything" data-input></textarea>
    <button class="btn ai-send" type="submit"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg><span class="visually-hidden">Send</span></button>
  </form>
  <p class="ai-note text-center">Quill can make mistakes. Check important details.</p>
  <p class="visually-hidden" role="status" data-status></p>
  <template data-tpl="user"><div class="ai-user"><p class="visually-hidden">You, <time data-time></time></p><p class="ai-bubble" data-text></p></div></template>
  <template data-tpl="bot"><div class="ai-bot" data-bot><span class="ai-mark" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span><div><p class="ai-who">Quill <time data-time></time></p><p class="ai-thinking" data-thinking hidden><span class="ai-dots" aria-hidden="true"><i></i><i></i><i></i></span>Quill is thinking</p><div class="ai-answer" data-answer></div><div class="ai-actions d-flex flex-wrap align-items-center gap-1 mt-2" role="group" aria-label="Response actions"><button class="btn ai-act d-flex align-items-center gap-2" type="button" data-regen><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg>Regenerate</button><button class="btn ai-act icon" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 11v9H4v-9zM7 11l4-8a2.5 2.5 0 0 1 2.5 2.5V9h5.2a2 2 0 0 1 2 2.3l-1.3 7A2 2 0 0 1 17.4 20H7"/></svg><span class="visually-hidden">Good response</span></button><button class="btn ai-act icon" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 13V4h3v9zM17 13l-4 8a2.5 2.5 0 0 1-2.5-2.5V15H5.3a2 2 0 0 1-2-2.3l1.3-7A2 2 0 0 1 6.6 4H17"/></svg><span class="visually-hidden">Bad response</span></button></div></div></div></template>
</div></div>

<div><span class="lbl">New chat with suggested prompts</span>
<div class="card ai fresh" data-assistant>
  <div class="card-header ai-head d-flex align-items-center gap-2">
    <span class="ai-mark" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span>
    <h2 class="fw-bold" id="ai-title-b">Quill</h2>
    <p>New chat</p>
  </div>
  <div class="ai-log" role="log" aria-live="polite" aria-labelledby="ai-title-b" tabindex="0" data-log>
    <div class="ai-empty" data-empty>
      <h3>Good morning, Sam. What are we writing today?</h3>
      <div class="ai-cards" role="group" aria-label="Suggested prompts">
        <button class="btn ai-card text-start d-flex flex-column" type="button" data-prompt><b>Shorten my workshop invite</b><span>Keep the date, time and price</span></button>
        <button class="btn ai-card text-start d-flex flex-column" type="button" data-prompt><b>Suggest three subject lines</b><span>For the April newsletter</span></button>
        <button class="btn ai-card text-start d-flex flex-column" type="button" data-prompt><b>Write a polite invoice reminder</b><span>Friendly, under 60 words</span></button>
        <button class="btn ai-card text-start d-flex flex-column" type="button" data-prompt><b>Explain CSS container queries</b><span>In two short paragraphs</span></button>
      </div>
    </div>
  </div>
  <form class="ai-composer d-flex align-items-end gap-2" data-composer>
    <label class="visually-hidden" for="ai-input-b">Message Quill</label>
    <textarea class="form-control" id="ai-input-b" rows="1" placeholder="Ask Quill anything" data-input></textarea>
    <button class="btn ai-send" type="submit"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg><span class="visually-hidden">Send</span></button>
  </form>
  <p class="ai-note text-center">Quill can make mistakes. Check important details.</p>
  <p class="visually-hidden" role="status" data-status></p>
  <template data-tpl="user"><div class="ai-user"><p class="visually-hidden">You, <time data-time></time></p><p class="ai-bubble" data-text></p></div></template>
  <template data-tpl="bot"><div class="ai-bot" data-bot><span class="ai-mark" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span><div><p class="ai-who">Quill <time data-time></time></p><p class="ai-thinking" data-thinking hidden><span class="ai-dots" aria-hidden="true"><i></i><i></i><i></i></span>Quill is thinking</p><div class="ai-answer" data-answer></div><div class="ai-actions d-flex flex-wrap align-items-center gap-1 mt-2" role="group" aria-label="Response actions"><button class="btn ai-act d-flex align-items-center gap-2" type="button" data-regen><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg>Regenerate</button><button class="btn ai-act icon" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 11v9H4v-9zM7 11l4-8a2.5 2.5 0 0 1 2.5 2.5V9h5.2a2 2 0 0 1 2 2.3l-1.3 7A2 2 0 0 1 17.4 20H7"/></svg><span class="visually-hidden">Good response</span></button><button class="btn ai-act icon" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 13V4h3v9zM17 13l-4 8a2.5 2.5 0 0 1-2.5-2.5V15H5.3a2 2 0 0 1-2-2.3l1.3-7A2 2 0 0 1 6.6 4H17"/></svg><span class="visually-hidden">Bad response</span></button></div></div></div></template>
</div></div>
CSS
/* AI assistant chat on Bootstrap: .card, .card-header, .btn for every action and .form-control in the composer.
   Replies stream in word by word (all at once under reduced motion); they are canned demo text and nothing is sent anywhere. */
.ai{--ai-accent:#b4451f;--ai-accent-dark:#923617;--ai-soft:#f8e4da;--ai-user:#efe9df;--ai-ink:#22201c;--ai-muted:#5f5a52;--ai-line:#e6dfd3;--ai-bg:#fffdf9;
  --ai-code-bg:#1f1d1a;--ai-code-ink:#f1ece4;--ai-serif:ui-serif,Georgia,"Times New Roman",serif;--ai-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
  --bs-card-bg:var(--ai-bg);--bs-card-border-color:var(--ai-line);--bs-card-border-radius:22px;--bs-card-inner-border-radius:21px;--bs-card-cap-bg:var(--ai-bg);--bs-card-cap-padding-y:14px;--bs-card-cap-padding-x:20px;--bs-card-color:var(--ai-ink);
  height:800px;overflow:hidden;font-size:.95rem;line-height:1.55;box-shadow:0 24px 50px -30px rgba(60,40,20,.35)}
.ai.fresh{height:560px}
.ai :where(h2,h3,p,pre){margin:0}
.ai :focus-visible{outline:2px solid var(--ai-accent);outline-offset:2px;box-shadow:none}
.ai .btn{--bs-btn-focus-box-shadow:none}
.ai-head{border-bottom-color:var(--ai-line)}
.ai-mark{flex:none;display:grid;place-items:center;width:30px;height:30px;border-radius:9px;background:var(--ai-accent);color:#fff}
.ai-head h2{font-size:1rem}
.ai-head p{margin-left:auto!important;font-size:.78rem;color:var(--ai-muted)}
.ai-log{flex:1;display:flex;flex-direction:column;gap:22px;overflow-y:auto;padding:24px 22px}
.ai-log:focus-visible{outline-offset:-3px}
.ai-user{align-self:flex-end;max-width:80%}
.ai-bubble{padding:10px 15px;border-radius:18px 18px 6px 18px;background:var(--ai-user);white-space:pre-wrap;overflow-wrap:anywhere}
.ai-bot{display:grid;grid-template-columns:30px minmax(0,1fr);gap:12px}
.ai-who{display:flex;align-items:baseline;gap:8px;margin-bottom:4px!important;font-size:.84rem;font-weight:700}
.ai-who time{font-size:.74rem;font-weight:400;color:var(--ai-muted)}
.ai-answer{display:flex;flex-direction:column;gap:10px}
.ai-answer code{color:inherit;font-size:inherit}
.ai-answer p code{padding:1px 5px;border-radius:5px;background:var(--ai-user);font-family:var(--ai-mono);font-size:.86em}
.ai-code{overflow:hidden;border-radius:14px;background:var(--ai-code-bg);color:var(--ai-code-ink)}
.ai-code-bar{display:flex;align-items:center;justify-content:space-between;padding:6px 8px 6px 14px;border-bottom:1px solid #35322d;font-size:.76rem;color:#c9c2b6}
.ai-copy{--bs-btn-padding-x:10px;--bs-btn-padding-y:5px;--bs-btn-font-size:.78rem;--bs-btn-border-radius:8px;--bs-btn-color:#f1ece4;--bs-btn-hover-color:#f1ece4;--bs-btn-hover-bg:#35322d;--bs-btn-active-color:#f1ece4;--bs-btn-active-bg:#35322d;--bs-btn-border-width:0}
.ai-code :focus-visible{outline-color:#f4a77f}
.ai-code pre{overflow-x:auto;padding:14px 16px;font-family:var(--ai-mono);font-size:.82rem;line-height:1.6;color:var(--ai-code-ink);tab-size:2}
.ai-code pre:focus-visible{outline-offset:-3px}
.ai-code .k{color:#f4a77f}.ai-code .s{color:#a8d8a0}.ai-code .c{color:#a39d93}.ai-code .f{color:#9ccfff}
.ai-thinking{display:flex;align-items:center;gap:8px;font-size:.86rem;color:var(--ai-muted)}
.ai-thinking[hidden]{display:none}
.ai-dots{display:flex;gap:4px}
.ai-dots i{width:7px;height:7px;border-radius:50%;background:var(--ai-accent);animation:ai-pulse 1.1s infinite ease-in-out}
.ai-dots i:nth-child(2){animation-delay:.18s}.ai-dots i:nth-child(3){animation-delay:.36s}
@keyframes ai-pulse{0%,80%,100%{opacity:.3;transform:scale(.8)}40%{opacity:1;transform:none}}
.ai-act{--bs-btn-padding-x:10px;--bs-btn-padding-y:0;--bs-btn-font-size:.8rem;--bs-btn-border-radius:9px;--bs-btn-color:var(--ai-muted);--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--ai-ink);--bs-btn-hover-bg:var(--ai-user);--bs-btn-hover-border-color:transparent;
  --bs-btn-active-color:var(--ai-accent-dark);--bs-btn-active-bg:var(--ai-soft);--bs-btn-active-border-color:var(--ai-accent);--bs-btn-disabled-color:var(--ai-muted);--bs-btn-disabled-border-color:transparent;height:32px}
.ai-act.icon{display:grid;place-items:center;width:32px;padding:0}
.ai-act[aria-pressed="true"]{border-color:var(--ai-accent);background:var(--ai-soft);color:var(--ai-accent-dark)}
.ai-act[aria-pressed="true"] svg{fill:currentColor}
.ai-prompts{flex-wrap:wrap;padding:0 20px 12px}
.ai-chip{--bs-btn-padding-x:13px;--bs-btn-padding-y:7px;--bs-btn-font-size:.84rem;--bs-btn-color:var(--ai-ink);--bs-btn-bg:var(--ai-bg);--bs-btn-border-color:#cfc6b8;--bs-btn-hover-color:var(--ai-ink);--bs-btn-hover-bg:var(--ai-soft);--bs-btn-hover-border-color:var(--ai-accent);--bs-btn-active-bg:var(--ai-soft);--bs-btn-active-border-color:var(--ai-accent);--bs-btn-line-height:1.3}
.ai-empty{flex:1;display:flex;flex-direction:column;justify-content:center;gap:22px;padding:4px 2px}
.ai-empty h3{font-family:var(--ai-serif);font-size:clamp(1.5rem,4vw,2rem);font-weight:500;line-height:1.2;letter-spacing:-.01em}
.ai-cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,280px),1fr));gap:10px}
.ai-card{--bs-btn-padding-x:16px;--bs-btn-padding-y:14px;--bs-btn-font-size:.9rem;--bs-btn-line-height:1.45;--bs-btn-border-radius:14px;--bs-btn-color:var(--ai-ink);--bs-btn-bg:#fff;--bs-btn-border-color:var(--ai-line);--bs-btn-hover-color:var(--ai-ink);--bs-btn-hover-bg:#fff;--bs-btn-hover-border-color:var(--ai-accent);--bs-btn-active-bg:#fff;--bs-btn-active-border-color:var(--ai-accent);gap:2px;transition:border-color .15s,transform .15s}
.ai-card:hover{transform:translateY(-2px)}
.ai-card span{font-size:.82rem;color:var(--ai-muted)}
.ai-composer{margin:0 16px 10px;padding:8px 8px 8px 16px;border:1.5px solid #9c9385;border-radius:20px;background:#fff}
.ai-composer:focus-within{border-color:var(--ai-accent);box-shadow:0 0 0 3px var(--ai-soft)}
.ai-composer .form-control{max-height:160px;padding:7px 0;border:0;border-radius:0;background:none;color:var(--ai-ink);font-size:inherit;line-height:1.45;resize:none;box-shadow:none}
.ai-composer .form-control:focus-visible{outline:none}
.ai-composer .form-control::placeholder{color:#6d675e}
.ai-send{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-radius:12px;--bs-btn-color:#fff;--bs-btn-bg:var(--ai-accent);--bs-btn-border-color:var(--ai-accent);--bs-btn-hover-color:#fff;--bs-btn-hover-bg:var(--ai-accent-dark);--bs-btn-hover-border-color:var(--ai-accent-dark);
  --bs-btn-active-color:#fff;--bs-btn-active-bg:var(--ai-accent-dark);--bs-btn-disabled-color:#5f5a52;--bs-btn-disabled-bg:#e3ddd2;--bs-btn-disabled-border-color:#e3ddd2;--bs-btn-disabled-opacity:1;display:grid;place-items:center;flex:none;width:38px;height:38px}
.ai-note{padding:0 20px 14px;font-size:.74rem;color:var(--ai-muted)}
@media (max-width:560px){.ai,.ai.fresh{height:680px}.ai-log{padding:18px 14px}.ai-prompts{flex-wrap:nowrap;overflow-x:auto;padding:0 12px 10px}.ai-chip{flex:none}.ai-bot{grid-template-columns:minmax(0,1fr)}.ai-bot>.ai-mark{display:none}.ai-composer{margin:0 10px 10px}}
@media (prefers-reduced-motion:reduce){.ai-dots i{animation:none;opacity:.7}.ai-card{transition:none}.ai-card:hover{transform:none}}
JavaScript
/* AI assistant demo: sends append your message, then a canned reply streams in word by word (instantly under reduced motion).
   Copy puts the code on the clipboard, Regenerate streams an alternative, and the two vote buttons are a pressed/unpressed pair.
   Replace the replies list with a call to your own model. */
document.querySelectorAll('[data-assistant]').forEach(function (app) {
  var log = app.querySelector('[data-log]'), form = app.querySelector('[data-composer]'), input = app.querySelector('[data-input]');
  var send = form.querySelector('[type="submit"]'), status = app.querySelector('[data-status]');
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)'), busy = false, turn = 0;
  var pad = function (n) { return String(n).padStart(2, '0'); };
  var replies = [
    'Our spring workshop runs on 12 April from 10:00 to 16:00 at Harbour Studio. Places are limited to 18, lunch and clay are included, and a seat costs 65 euros.',
    'Three to test: "Your spring workshop seat is waiting", "18 places, one Saturday, all the clay you need" and "Last call for April at Harbour Studio".',
    'Hi Jonas, just checking whether you had a chance to look at the September invoice. Happy to resend it or split the payment if that helps. Thanks, Sam',
    'Container queries let a component respond to the width of its parent instead of the window. Mark the parent with container-type: inline-size, then write @container (min-width: 400px) rules for the child.'
  ];
  var alt = 'Another way: listen for the input event, keep the timeout id in a variable and clear it on every keystroke. When the timeout finally fires, search with the current value. 250 ms is a good first delay.';

  function stamp(t) { var d = new Date(); t.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes()); t.dateTime = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + t.textContent; }
  function add(kind) {
    var node = app.querySelector('template[data-tpl="' + kind + '"]').content.firstElementChild.cloneNode(true);
    stamp(node.querySelector('[data-time]'));
    log.appendChild(node); log.scrollTop = log.scrollHeight;
    return node;
  }
  function setBusy(on) {
    busy = on;
    if (on) log.setAttribute('aria-busy', 'true'); else log.removeAttribute('aria-busy');
    app.querySelectorAll('[data-regen]').forEach(function (b) { b.disabled = on; });
    grow();
  }
  function stream(bot, text) {
    var answer = bot.querySelector('[data-answer]'), thinking = bot.querySelector('[data-thinking]');
    setBusy(true); answer.textContent = ''; thinking.hidden = false;
    setTimeout(function () {
      thinking.hidden = true;
      var p = document.createElement('p'); answer.appendChild(p);
      if (reduce.matches) { p.textContent = text; log.scrollTop = log.scrollHeight; return setBusy(false); }
      var words = text.split(' '), i = 0;
      var timer = setInterval(function () {
        p.textContent += (i ? ' ' : '') + words[i++];
        log.scrollTop = log.scrollHeight;
        if (i >= words.length) { clearInterval(timer); setBusy(false); }
      }, 45);
    }, reduce.matches ? 250 : 900);
  }
  function ask(text) {
    if (busy || !text) return;
    var empty = log.querySelector('[data-empty]');
    if (empty) empty.remove();
    add('user').querySelector('[data-text]').textContent = text;
    stream(add('bot'), replies[turn++ % replies.length]);
  }

  function grow() { input.style.height = 'auto'; input.style.height = input.scrollHeight + 'px'; send.disabled = busy || !input.value.trim(); }
  input.addEventListener('input', grow);
  input.addEventListener('keydown', function (e) {
    if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); }
  });
  form.addEventListener('submit', function (e) {
    e.preventDefault();
    var text = input.value.trim();
    if (!text || busy) return;
    input.value = ''; ask(text); input.focus();
  });

  app.addEventListener('click', function (e) {
    var b = e.target.closest('button');
    if (!b) return;
    if (b.hasAttribute('data-prompt')) { ask((b.querySelector('b') || b).textContent); input.focus(); }
    if (b.hasAttribute('data-regen')) stream(b.closest('[data-bot]'), alt);
    if (b.hasAttribute('data-vote')) {
      var on = b.getAttribute('aria-pressed') !== 'true';
      b.parentNode.querySelectorAll('[data-vote]').forEach(function (v) { v.setAttribute('aria-pressed', 'false'); });
      b.setAttribute('aria-pressed', String(on));
      status.textContent = on ? 'Thanks for the feedback' : '';
    }
    if (b.hasAttribute('data-copy')) {
      var code = b.closest('[data-code]').querySelector('code').textContent, label = b.querySelector('[data-copy-label]');
      var done = function () { label.textContent = 'Copied'; status.textContent = 'Code copied'; setTimeout(function () { label.textContent = 'Copy'; }, 2000); };
      if (navigator.clipboard) navigator.clipboard.writeText(code).then(done, function () { status.textContent = 'Copy failed, select the code instead'; });
      else status.textContent = 'Copy is not available, select the code instead';
    }
  });
  log.scrollTop = log.scrollHeight;
  grow();
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-ai-muted uppercase">Conversation with a code answer, copy, regenerate and feedback</span>
<div class="flex h-[800px] flex-col overflow-hidden rounded-[22px] border border-ai-line bg-ai-bg text-[.95rem] leading-[1.55] text-ai-ink shadow-[0_24px_50px_-30px_rgba(60,40,20,.35)] max-[560px]:h-[680px]" data-assistant>
  <div class="flex items-center gap-2.5 border-b border-ai-line px-5 py-3.5">
    <span class="grid size-[30px] flex-none place-items-center rounded-[9px] bg-ai-accent text-white" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span>
    <h2 class="text-base font-bold" id="ai-title-a">Quill</h2>
    <p class="ml-auto text-[.78rem] text-ai-muted">Writing and code helper</p>
  </div>
  <div class="flex flex-1 flex-col gap-[22px] overflow-y-auto px-[22px] py-6 focus-visible:outline-2 focus-visible:-outline-offset-[3px] focus-visible:outline-ai-accent max-[560px]:px-3.5 max-[560px]:py-[18px]" role="log" aria-live="polite" aria-labelledby="ai-title-a" tabindex="0" data-log>
    <div class="max-w-[80%] self-end"><p class="sr-only">You, <time datetime="2026-10-14T10:02">10:02</time></p><p class="rounded-[18px_18px_6px_18px] bg-ai-user px-[15px] py-2.5 whitespace-pre-wrap [overflow-wrap:anywhere]">How do I debounce a search input in plain JavaScript?</p></div>
    <div class="grid grid-cols-[30px_minmax(0,1fr)] gap-3 max-[560px]:grid-cols-[minmax(0,1fr)]" data-bot>
      <span class="grid size-[30px] flex-none place-items-center rounded-[9px] bg-ai-accent text-white max-[560px]:hidden" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span>
      <div>
        <p class="mb-1 flex items-baseline gap-2 text-[.84rem] font-bold">Quill <time class="text-[.74rem] font-normal text-ai-muted" datetime="2026-10-14T10:02">10:02</time></p>
        <p class="flex items-center gap-2 text-[.86rem] text-ai-muted" data-thinking hidden><span class="flex gap-1" aria-hidden="true"><i class="size-[7px] animate-ai-pulse rounded-full bg-ai-accent motion-reduce:animate-none motion-reduce:opacity-70"></i><i class="size-[7px] animate-ai-pulse rounded-full bg-ai-accent [animation-delay:.18s] motion-reduce:animate-none motion-reduce:opacity-70"></i><i class="size-[7px] animate-ai-pulse rounded-full bg-ai-accent [animation-delay:.36s] motion-reduce:animate-none motion-reduce:opacity-70"></i></span>Quill is thinking</p>
        <div class="flex flex-col gap-2.5" data-answer>
          <p>Wrap the handler so it only runs after the user stops typing. Every keystroke clears the previous timer and starts a new one:</p>
          <div class="overflow-hidden rounded-[14px] bg-[#1f1d1a] text-[#f1ece4]" data-code>
            <div class="flex items-center justify-between border-b border-[#35322d] py-1.5 pr-2 pl-3.5 text-[.76rem] text-[#c9c2b6]"><span>JavaScript</span><button class="flex cursor-pointer items-center gap-1.5 rounded-lg px-2.5 py-[5px] text-[.78rem] text-[#f1ece4] hover:bg-[#35322d] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#f4a77f]" type="button" data-copy><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg><span data-copy-label>Copy</span><span class="sr-only"> code</span></button></div>
<pre class="overflow-x-auto px-4 py-3.5 font-mono text-[.82rem] leading-[1.6] [tab-size:2] focus-visible:outline-2 focus-visible:-outline-offset-[3px] focus-visible:outline-[#f4a77f]" tabindex="0" aria-label="JavaScript code"><code><span class="text-[#f4a77f]">function</span> <span class="text-[#9ccfff]">debounce</span>(fn, wait = 250) {
  <span class="text-[#f4a77f]">let</span> timer;
  <span class="text-[#f4a77f]">return</span> (...args) => {
    <span class="text-[#9ccfff]">clearTimeout</span>(timer);
    timer = <span class="text-[#9ccfff]">setTimeout</span>(() => fn(...args), wait);
  };
}

search.<span class="text-[#9ccfff]">addEventListener</span>(<span class="text-[#a8d8a0]">'input'</span>, <span class="text-[#9ccfff]">debounce</span>(e => {
  <span class="text-[#9ccfff]">fetchResults</span>(e.target.value);
}));</code></pre>
          </div>
          <p>A wait of 200 to 300 ms still feels instant and cuts most of the requests. Use <code class="rounded-[5px] bg-ai-user px-[5px] py-px font-mono text-[.86em]">wait = 0</code> to turn it off while testing.</p>
        </div>
        <div class="mt-2 flex flex-wrap items-center gap-1" role="group" aria-label="Response actions">
          <button class="flex h-8 cursor-pointer items-center gap-1.5 rounded-[9px] border border-transparent px-2.5 text-[.8rem] text-ai-muted hover:bg-ai-user hover:text-ai-ink disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" data-regen><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg>Regenerate</button>
          <button class="grid size-8 cursor-pointer place-items-center rounded-[9px] border border-transparent text-ai-muted hover:bg-ai-user hover:text-ai-ink aria-pressed:border-ai-accent aria-pressed:bg-ai-soft aria-pressed:text-ai-accent-dark aria-pressed:*:fill-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 11v9H4v-9zM7 11l4-8a2.5 2.5 0 0 1 2.5 2.5V9h5.2a2 2 0 0 1 2 2.3l-1.3 7A2 2 0 0 1 17.4 20H7"/></svg><span class="sr-only">Good response</span></button>
          <button class="grid size-8 cursor-pointer place-items-center rounded-[9px] border border-transparent text-ai-muted hover:bg-ai-user hover:text-ai-ink aria-pressed:border-ai-accent aria-pressed:bg-ai-soft aria-pressed:text-ai-accent-dark aria-pressed:*:fill-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 13V4h3v9zM17 13l-4 8a2.5 2.5 0 0 1-2.5-2.5V15H5.3a2 2 0 0 1-2-2.3l1.3-7A2 2 0 0 1 6.6 4H17"/></svg><span class="sr-only">Bad response</span></button>
        </div>
      </div>
    </div>
  </div>
  <div class="flex flex-wrap gap-2 px-5 pb-3 max-[560px]:flex-nowrap max-[560px]:overflow-x-auto max-[560px]:px-3 max-[560px]:pb-2.5" role="group" aria-label="Suggested prompts">
    <button class="flex-none cursor-pointer rounded-full border border-[#cfc6b8] bg-ai-bg px-[13px] py-[7px] text-[.84rem] hover:border-ai-accent hover:bg-ai-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" data-prompt>Shorten my workshop invite</button>
    <button class="flex-none cursor-pointer rounded-full border border-[#cfc6b8] bg-ai-bg px-[13px] py-[7px] text-[.84rem] hover:border-ai-accent hover:bg-ai-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" data-prompt>Suggest three subject lines</button>
    <button class="flex-none cursor-pointer rounded-full border border-[#cfc6b8] bg-ai-bg px-[13px] py-[7px] text-[.84rem] hover:border-ai-accent hover:bg-ai-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" data-prompt>Write a polite invoice reminder</button>
  </div>
  <form class="mx-4 mb-2.5 flex items-end gap-2 rounded-[20px] border-[1.5px] border-[#9c9385] bg-white py-2 pr-2 pl-4 focus-within:border-ai-accent focus-within:shadow-[0_0_0_3px_var(--color-ai-soft)] max-[560px]:mx-2.5" data-composer>
    <label class="sr-only" for="ai-input-a">Message Quill</label>
    <textarea class="max-h-40 min-w-0 flex-1 resize-none bg-transparent py-[7px] leading-[1.45] text-ai-ink placeholder:text-[#6d675e] focus-visible:outline-none" id="ai-input-a" rows="1" placeholder="Ask Quill anything" data-input></textarea>
    <button class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-xl bg-ai-accent text-white hover:bg-ai-accent-dark disabled:cursor-not-allowed disabled:bg-[#e3ddd2] disabled:text-ai-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="submit"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg><span class="sr-only">Send</span></button>
  </form>
  <p class="px-5 pb-3.5 text-center text-[.74rem] text-ai-muted">Quill can make mistakes. Check important details.</p>
  <p class="sr-only" role="status" data-status></p>
  <template data-tpl="user"><div class="max-w-[80%] self-end"><p class="sr-only">You, <time data-time></time></p><p class="rounded-[18px_18px_6px_18px] bg-ai-user px-[15px] py-2.5 whitespace-pre-wrap [overflow-wrap:anywhere]" data-text></p></div></template>
  <template data-tpl="bot"><div class="grid grid-cols-[30px_minmax(0,1fr)] gap-3 max-[560px]:grid-cols-[minmax(0,1fr)]" data-bot><span class="grid size-[30px] flex-none place-items-center rounded-[9px] bg-ai-accent text-white max-[560px]:hidden" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span><div><p class="mb-1 flex items-baseline gap-2 text-[.84rem] font-bold">Quill <time class="text-[.74rem] font-normal text-ai-muted" data-time></time></p><p class="flex items-center gap-2 text-[.86rem] text-ai-muted" data-thinking hidden><span class="flex gap-1" aria-hidden="true"><i class="size-[7px] animate-ai-pulse rounded-full bg-ai-accent motion-reduce:animate-none motion-reduce:opacity-70"></i><i class="size-[7px] animate-ai-pulse rounded-full bg-ai-accent [animation-delay:.18s] motion-reduce:animate-none motion-reduce:opacity-70"></i><i class="size-[7px] animate-ai-pulse rounded-full bg-ai-accent [animation-delay:.36s] motion-reduce:animate-none motion-reduce:opacity-70"></i></span>Quill is thinking</p><div class="flex flex-col gap-2.5" data-answer></div><div class="mt-2 flex flex-wrap items-center gap-1" role="group" aria-label="Response actions"><button class="flex h-8 cursor-pointer items-center gap-1.5 rounded-[9px] border border-transparent px-2.5 text-[.8rem] text-ai-muted hover:bg-ai-user hover:text-ai-ink disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" data-regen><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg>Regenerate</button><button class="grid size-8 cursor-pointer place-items-center rounded-[9px] border border-transparent text-ai-muted hover:bg-ai-user hover:text-ai-ink aria-pressed:border-ai-accent aria-pressed:bg-ai-soft aria-pressed:text-ai-accent-dark aria-pressed:*:fill-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 11v9H4v-9zM7 11l4-8a2.5 2.5 0 0 1 2.5 2.5V9h5.2a2 2 0 0 1 2 2.3l-1.3 7A2 2 0 0 1 17.4 20H7"/></svg><span class="sr-only">Good response</span></button><button class="grid size-8 cursor-pointer place-items-center rounded-[9px] border border-transparent text-ai-muted hover:bg-ai-user hover:text-ai-ink aria-pressed:border-ai-accent aria-pressed:bg-ai-soft aria-pressed:text-ai-accent-dark aria-pressed:*:fill-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 13V4h3v9zM17 13l-4 8a2.5 2.5 0 0 1-2.5-2.5V15H5.3a2 2 0 0 1-2-2.3l1.3-7A2 2 0 0 1 6.6 4H17"/></svg><span class="sr-only">Bad response</span></button></div></div></div></template>
</div></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-ai-muted uppercase">New chat with suggested prompts</span>
<div class="flex h-[560px] flex-col overflow-hidden rounded-[22px] border border-ai-line bg-ai-bg text-[.95rem] leading-[1.55] text-ai-ink shadow-[0_24px_50px_-30px_rgba(60,40,20,.35)] max-[560px]:h-[680px]" data-assistant>
  <div class="flex items-center gap-2.5 border-b border-ai-line px-5 py-3.5">
    <span class="grid size-[30px] flex-none place-items-center rounded-[9px] bg-ai-accent text-white" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span>
    <h2 class="text-base font-bold" id="ai-title-b">Quill</h2>
    <p class="ml-auto text-[.78rem] text-ai-muted">New chat</p>
  </div>
  <div class="flex flex-1 flex-col gap-[22px] overflow-y-auto px-[22px] py-6 focus-visible:outline-2 focus-visible:-outline-offset-[3px] focus-visible:outline-ai-accent max-[560px]:px-3.5 max-[560px]:py-[18px]" role="log" aria-live="polite" aria-labelledby="ai-title-b" tabindex="0" data-log>
    <div class="flex flex-1 flex-col justify-center gap-[22px] px-0.5 py-1" data-empty>
      <h3 class="font-serif text-[clamp(1.5rem,4vw,2rem)] leading-[1.2] font-medium tracking-[-.01em]">Good morning, Sam. What are we writing today?</h3>
      <div class="grid grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-2.5" role="group" aria-label="Suggested prompts">
        <button class="flex cursor-pointer flex-col gap-0.5 rounded-[14px] border border-ai-line bg-white px-4 py-3.5 text-left transition-[border-color,transform] duration-150 hover:-translate-y-0.5 hover:border-ai-accent motion-reduce:transition-none motion-reduce:hover:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" data-prompt><b class="text-[.9rem]">Shorten my workshop invite</b><span class="text-[.82rem] text-ai-muted">Keep the date, time and price</span></button>
        <button class="flex cursor-pointer flex-col gap-0.5 rounded-[14px] border border-ai-line bg-white px-4 py-3.5 text-left transition-[border-color,transform] duration-150 hover:-translate-y-0.5 hover:border-ai-accent motion-reduce:transition-none motion-reduce:hover:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" data-prompt><b class="text-[.9rem]">Suggest three subject lines</b><span class="text-[.82rem] text-ai-muted">For the April newsletter</span></button>
        <button class="flex cursor-pointer flex-col gap-0.5 rounded-[14px] border border-ai-line bg-white px-4 py-3.5 text-left transition-[border-color,transform] duration-150 hover:-translate-y-0.5 hover:border-ai-accent motion-reduce:transition-none motion-reduce:hover:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" data-prompt><b class="text-[.9rem]">Write a polite invoice reminder</b><span class="text-[.82rem] text-ai-muted">Friendly, under 60 words</span></button>
        <button class="flex cursor-pointer flex-col gap-0.5 rounded-[14px] border border-ai-line bg-white px-4 py-3.5 text-left transition-[border-color,transform] duration-150 hover:-translate-y-0.5 hover:border-ai-accent motion-reduce:transition-none motion-reduce:hover:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" data-prompt><b class="text-[.9rem]">Explain CSS container queries</b><span class="text-[.82rem] text-ai-muted">In two short paragraphs</span></button>
      </div>
    </div>
  </div>
  <form class="mx-4 mb-2.5 flex items-end gap-2 rounded-[20px] border-[1.5px] border-[#9c9385] bg-white py-2 pr-2 pl-4 focus-within:border-ai-accent focus-within:shadow-[0_0_0_3px_var(--color-ai-soft)] max-[560px]:mx-2.5" data-composer>
    <label class="sr-only" for="ai-input-b">Message Quill</label>
    <textarea class="max-h-40 min-w-0 flex-1 resize-none bg-transparent py-[7px] leading-[1.45] text-ai-ink placeholder:text-[#6d675e] focus-visible:outline-none" id="ai-input-b" rows="1" placeholder="Ask Quill anything" data-input></textarea>
    <button class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-xl bg-ai-accent text-white hover:bg-ai-accent-dark disabled:cursor-not-allowed disabled:bg-[#e3ddd2] disabled:text-ai-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="submit"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M5 12l7-7 7 7"/></svg><span class="sr-only">Send</span></button>
  </form>
  <p class="px-5 pb-3.5 text-center text-[.74rem] text-ai-muted">Quill can make mistakes. Check important details.</p>
  <p class="sr-only" role="status" data-status></p>
  <template data-tpl="user"><div class="max-w-[80%] self-end"><p class="sr-only">You, <time data-time></time></p><p class="rounded-[18px_18px_6px_18px] bg-ai-user px-[15px] py-2.5 whitespace-pre-wrap [overflow-wrap:anywhere]" data-text></p></div></template>
  <template data-tpl="bot"><div class="grid grid-cols-[30px_minmax(0,1fr)] gap-3 max-[560px]:grid-cols-[minmax(0,1fr)]" data-bot><span class="grid size-[30px] flex-none place-items-center rounded-[9px] bg-ai-accent text-white max-[560px]:hidden" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l2.2 6.6L21 11l-6.8 2.4L12 20l-2.2-6.6L3 11l6.8-2.4z"/></svg></span><div><p class="mb-1 flex items-baseline gap-2 text-[.84rem] font-bold">Quill <time class="text-[.74rem] font-normal text-ai-muted" data-time></time></p><p class="flex items-center gap-2 text-[.86rem] text-ai-muted" data-thinking hidden><span class="flex gap-1" aria-hidden="true"><i class="size-[7px] animate-ai-pulse rounded-full bg-ai-accent motion-reduce:animate-none motion-reduce:opacity-70"></i><i class="size-[7px] animate-ai-pulse rounded-full bg-ai-accent [animation-delay:.18s] motion-reduce:animate-none motion-reduce:opacity-70"></i><i class="size-[7px] animate-ai-pulse rounded-full bg-ai-accent [animation-delay:.36s] motion-reduce:animate-none motion-reduce:opacity-70"></i></span>Quill is thinking</p><div class="flex flex-col gap-2.5" data-answer></div><div class="mt-2 flex flex-wrap items-center gap-1" role="group" aria-label="Response actions"><button class="flex h-8 cursor-pointer items-center gap-1.5 rounded-[9px] border border-transparent px-2.5 text-[.8rem] text-ai-muted hover:bg-ai-user hover:text-ai-ink disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" data-regen><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg>Regenerate</button><button class="grid size-8 cursor-pointer place-items-center rounded-[9px] border border-transparent text-ai-muted hover:bg-ai-user hover:text-ai-ink aria-pressed:border-ai-accent aria-pressed:bg-ai-soft aria-pressed:text-ai-accent-dark aria-pressed:*:fill-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 11v9H4v-9zM7 11l4-8a2.5 2.5 0 0 1 2.5 2.5V9h5.2a2 2 0 0 1 2 2.3l-1.3 7A2 2 0 0 1 17.4 20H7"/></svg><span class="sr-only">Good response</span></button><button class="grid size-8 cursor-pointer place-items-center rounded-[9px] border border-transparent text-ai-muted hover:bg-ai-user hover:text-ai-ink aria-pressed:border-ai-accent aria-pressed:bg-ai-soft aria-pressed:text-ai-accent-dark aria-pressed:*:fill-current focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ai-accent" type="button" aria-pressed="false" data-vote><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 13V4h3v9zM17 13l-4 8a2.5 2.5 0 0 1-2.5-2.5V15H5.3a2 2 0 0 1-2-2.3l1.3-7A2 2 0 0 1 6.6 4H17"/></svg><span class="sr-only">Bad response</span></button></div></div></div></template>
</div></div>
CSS
/* Assistant palette and the thinking-dot keyframe (switched off with motion-reduce:). */
@theme {
  --color-ai-accent:#b4451f; --color-ai-accent-dark:#923617; --color-ai-soft:#f8e4da; --color-ai-user:#efe9df;
  --color-ai-ink:#22201c; --color-ai-muted:#5f5a52; --color-ai-line:#e6dfd3; --color-ai-bg:#fffdf9;
  --animate-ai-pulse: ai-pulse 1.1s infinite ease-in-out;
  @keyframes ai-pulse { 0%,80%,100% { opacity:.3; transform:scale(.8) } 40% { opacity:1; transform:none } }
}
JavaScript
/* AI assistant demo: sends append your message, then a canned reply streams in word by word (instantly under reduced motion).
   Copy puts the code on the clipboard, Regenerate streams an alternative, and the two vote buttons are a pressed/unpressed pair.
   Replace the replies list with a call to your own model. */
document.querySelectorAll('[data-assistant]').forEach(function (app) {
  var log = app.querySelector('[data-log]'), form = app.querySelector('[data-composer]'), input = app.querySelector('[data-input]');
  var send = form.querySelector('[type="submit"]'), status = app.querySelector('[data-status]');
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)'), busy = false, turn = 0;
  var pad = function (n) { return String(n).padStart(2, '0'); };
  var replies = [
    'Our spring workshop runs on 12 April from 10:00 to 16:00 at Harbour Studio. Places are limited to 18, lunch and clay are included, and a seat costs 65 euros.',
    'Three to test: "Your spring workshop seat is waiting", "18 places, one Saturday, all the clay you need" and "Last call for April at Harbour Studio".',
    'Hi Jonas, just checking whether you had a chance to look at the September invoice. Happy to resend it or split the payment if that helps. Thanks, Sam',
    'Container queries let a component respond to the width of its parent instead of the window. Mark the parent with container-type: inline-size, then write @container (min-width: 400px) rules for the child.'
  ];
  var alt = 'Another way: listen for the input event, keep the timeout id in a variable and clear it on every keystroke. When the timeout finally fires, search with the current value. 250 ms is a good first delay.';

  function stamp(t) { var d = new Date(); t.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes()); t.dateTime = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + t.textContent; }
  function add(kind) {
    var node = app.querySelector('template[data-tpl="' + kind + '"]').content.firstElementChild.cloneNode(true);
    stamp(node.querySelector('[data-time]'));
    log.appendChild(node); log.scrollTop = log.scrollHeight;
    return node;
  }
  function setBusy(on) {
    busy = on;
    if (on) log.setAttribute('aria-busy', 'true'); else log.removeAttribute('aria-busy');
    app.querySelectorAll('[data-regen]').forEach(function (b) { b.disabled = on; });
    grow();
  }
  function stream(bot, text) {
    var answer = bot.querySelector('[data-answer]'), thinking = bot.querySelector('[data-thinking]');
    setBusy(true); answer.textContent = ''; thinking.hidden = false;
    setTimeout(function () {
      thinking.hidden = true;
      var p = document.createElement('p'); answer.appendChild(p);
      if (reduce.matches) { p.textContent = text; log.scrollTop = log.scrollHeight; return setBusy(false); }
      var words = text.split(' '), i = 0;
      var timer = setInterval(function () {
        p.textContent += (i ? ' ' : '') + words[i++];
        log.scrollTop = log.scrollHeight;
        if (i >= words.length) { clearInterval(timer); setBusy(false); }
      }, 45);
    }, reduce.matches ? 250 : 900);
  }
  function ask(text) {
    if (busy || !text) return;
    var empty = log.querySelector('[data-empty]');
    if (empty) empty.remove();
    add('user').querySelector('[data-text]').textContent = text;
    stream(add('bot'), replies[turn++ % replies.length]);
  }

  function grow() { input.style.height = 'auto'; input.style.height = input.scrollHeight + 'px'; send.disabled = busy || !input.value.trim(); }
  input.addEventListener('input', grow);
  input.addEventListener('keydown', function (e) {
    if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); }
  });
  form.addEventListener('submit', function (e) {
    e.preventDefault();
    var text = input.value.trim();
    if (!text || busy) return;
    input.value = ''; ask(text); input.focus();
  });

  app.addEventListener('click', function (e) {
    var b = e.target.closest('button');
    if (!b) return;
    if (b.hasAttribute('data-prompt')) { ask((b.querySelector('b') || b).textContent); input.focus(); }
    if (b.hasAttribute('data-regen')) stream(b.closest('[data-bot]'), alt);
    if (b.hasAttribute('data-vote')) {
      var on = b.getAttribute('aria-pressed') !== 'true';
      b.parentNode.querySelectorAll('[data-vote]').forEach(function (v) { v.setAttribute('aria-pressed', 'false'); });
      b.setAttribute('aria-pressed', String(on));
      status.textContent = on ? 'Thanks for the feedback' : '';
    }
    if (b.hasAttribute('data-copy')) {
      var code = b.closest('[data-code]').querySelector('code').textContent, label = b.querySelector('[data-copy-label]');
      var done = function () { label.textContent = 'Copied'; status.textContent = 'Code copied'; setTimeout(function () { label.textContent = 'Copy'; }, 2000); };
      if (navigator.clipboard) navigator.clipboard.writeText(code).then(done, function () { status.textContent = 'Copy failed, select the code instead'; });
      else status.textContent = 'Copy is not available, select the code instead';
    }
  });
  log.scrollTop = log.scrollHeight;
  grow();
});

About this set

An AI assistant chat with the details people expect from assistant tools, without calling any model. The conversation variant shows a question, an answer with paragraphs and a code block, and a composer with suggested prompts above it. The code block has a language label and a Copy button that uses the Clipboard API and confirms with Copied. Each answer has a Regenerate button and Good response and Bad response buttons that work as a pressed or unpressed pair. Sending or picking a prompt adds your message, shows a Quill is thinking indicator with three pulsing dots, then streams a canned reply word by word; under prefers-reduced-motion the dots hold still and the reply appears at once. The new chat variant starts on a serif greeting with four prompt cards that disappear once the first message is sent. While a reply streams, the log carries aria-busy so screen readers can wait for the finished text, and Send and Regenerate are disabled. Swap the list of canned replies in the script for a call to your own model.

What’s included

  • Word-by-word streaming that is instant under reduced motion
  • Code block with a language label and a Copy button
  • Regenerate plus Good and Bad response toggle buttons
  • Suggested prompt chips and an empty state with prompt cards
  • No API calls; canned replies stand in for a model

Accessibility

The conversation is a role="log" region with aria-live="polite", and aria-busy is set while a reply streams so the finished answer is read once. The feedback buttons use aria-pressed and have hidden labels; copying and feedback are confirmed in a visually hidden role="status" line, and the code block is a focusable, named scroll region. Every control shows a focus outline, and the thinking dots and the streaming stop under prefers-reduced-motion.

Customise it

Change --ai-accent, --ai-user, --ai-bg and --ai-code-bg on .ai, and the token colours .k, .s and .f inside the code block. In Tailwind edit the --color-ai-* theme colours; in Bootstrap the same --ai-* variables sit on the .card.