Open demo

CSS code

HTML
<div><span class="lbl">Pill composer with an emoji picker</span>
<div class="cp" data-chat>
  <div class="cp-log" role="log" aria-live="polite" aria-label="Chat with Nadia Rahman" tabindex="0" data-log>
    <div class="cp-msg"><p class="cp-who">Nadia Rahman</p><p class="cp-b"><span>The sourdough starter survived the weekend.</span> <time datetime="2026-10-14T19:02">19:02</time></p></div>
    <div class="cp-msg out"><p class="cp-who sr-only">You</p><p class="cp-b"><span>Finally. Bake on Saturday?</span> <time datetime="2026-10-14T19:04">19:04</time></p></div>
  </div>
  <form class="cp-form" data-form>
    <div class="cp-emoji" id="cp-emoji-a" role="group" aria-label="Emoji" hidden data-emoji-panel>
      <button type="button" data-insert="😀" aria-label="Grinning face">😀</button><button type="button" data-insert="😂" aria-label="Tears of joy">😂</button><button type="button" data-insert="😍" aria-label="Heart eyes">😍</button><button type="button" data-insert="👍" aria-label="Thumbs up">👍</button>
      <button type="button" data-insert="🙏" aria-label="Thank you">🙏</button><button type="button" data-insert="🎉" aria-label="Party popper">🎉</button><button type="button" data-insert="🍞" aria-label="Bread">🍞</button><button type="button" data-insert="❤️" aria-label="Red heart">❤️</button>
    </div>
    <div class="cp-pill">
      <button class="cp-icon" type="button" aria-expanded="false" aria-controls="cp-emoji-a" data-emoji-toggle><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8.5 14.5a4.5 4.5 0 0 0 7 0M9 9.5h.01M15 9.5h.01"/></svg><span class="sr-only">Emoji</span></button>
      <label class="sr-only" for="cp-input-a">Message to Nadia</label>
      <textarea class="cp-input" id="cp-input-a" rows="1" placeholder="Message" data-input></textarea>
      <button class="cp-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>
    </div>
  </form>
  <template data-tpl><div class="cp-msg out"><p class="cp-who sr-only">You</p><p class="cp-b"><span data-text></span> <time data-time></time><span class="cp-files" data-files hidden></span></p></div></template>
</div></div>

<div><span class="lbl">Attachments as removable chips</span>
<div class="cp" data-chat>
  <div class="cp-log" role="log" aria-live="polite" aria-label="Chat with Halden Estates" tabindex="0" data-log>
    <div class="cp-msg"><p class="cp-who">Marco from Halden Estates</p><p class="cp-b"><span>Could you send the signed lease and a photo of your ID?</span> <time datetime="2026-10-14T09:40">09:40</time></p></div>
  </div>
  <form class="cp-form" data-form>
    <div class="cp-card">
      <ul class="cp-chips" aria-label="Attachments" data-chips><li class="cp-chip" data-chip><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="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg><span data-name>lease-signed.pdf</span><small>840 KB</small><button type="button" data-remove><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg><span class="sr-only">Remove lease-signed.pdf</span></button></li><li class="cp-chip" data-chip><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"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M21 17l-5-5-9 8"/></svg><span data-name>id-card-front.jpg</span><small>1.9 MB</small><button type="button" data-remove><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg><span class="sr-only">Remove id-card-front.jpg</span></button></li></ul>
      <div class="cp-card-row">
        <label class="sr-only" for="cp-input-b">Message to Halden Estates</label>
        <textarea class="cp-input" id="cp-input-b" rows="1" placeholder="Add a message" data-input></textarea>
        <button class="cp-icon" type="button" data-attach><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11.5 12.6 20a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8"/></svg><span class="sr-only">Attach files</span></button>
        <input type="file" multiple hidden data-file>
        <button class="cp-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>
      </div>
    </div>
  </form>
  <p class="sr-only" role="status" data-status></p>
  <template data-tpl><div class="cp-msg out"><p class="cp-who sr-only">You</p><p class="cp-b"><span data-text></span> <time data-time></time><span class="cp-files" data-files hidden></span></p></div></template>
  <template data-tpl="chip"><li class="cp-chip" data-chip><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="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg><span data-name></span><small data-size></small><button type="button" data-remove><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg><span class="sr-only" data-remove-label></span></button></li></template>
</div></div>

<div><span class="lbl">Toolbar composer with a character count and key hint</span>
<div class="cp" data-chat>
  <div class="cp-log" role="log" aria-live="polite" aria-label="Channel kitchen-renovation" tabindex="0" data-log>
    <div class="cp-msg"><p class="cp-who">Theo Lindqvist</p><p class="cp-b"><span>Tiles arrive Thursday. @Sam can you confirm the grout colour?</span> <time datetime="2026-10-14T14:15">14:15</time></p></div>
  </div>
  <form class="cp-form" data-form>
    <div class="cp-tool">
      <label class="sr-only" for="cp-input-c">Message to kitchen-renovation</label>
      <textarea class="cp-input" id="cp-input-c" rows="2" maxlength="500" placeholder="Write to #kitchen-renovation" aria-describedby="cp-hint-c" data-input></textarea>
      <div class="cp-bar">
        <button class="cp-icon" type="button" data-insert="@"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/></svg><span class="sr-only">Mention someone</span></button>
        <button class="cp-icon" type="button" data-insert="👍" aria-label="Insert thumbs up"><span aria-hidden="true">👍</span></button>
        <span class="cp-count" data-count>0 / 500</span>
        <button class="cp-send text" type="submit">Send<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></button>
      </div>
    </div>
    <p class="cp-hint" id="cp-hint-c"><kbd>Enter</kbd> to send, <kbd>Shift</kbd> + <kbd>Enter</kbd> for a new line</p>
  </form>
  <template data-tpl><div class="cp-msg out"><p class="cp-who sr-only">You</p><p class="cp-b"><span data-text></span> <time data-time></time><span class="cp-files" data-files hidden></span></p></div></template>
</div></div>
CSS
/* Chat composers: a textarea that grows with its text, Enter to send and Shift+Enter for a new line,
   a send button that is only enabled when there is something to send, an emoji picker, attachment chips and a counter. */
.cp{--cp-accent:#1d4ed8;--cp-accent-dark:#1e40af;--cp-soft:#e3ebff;--cp-in:#eef1f7;--cp-ink:#172036;--cp-muted:#56607a;--cp-line:#dfe4ee;--cp-field:#8790a6;--cp-bg:#fff;
  overflow:hidden;background:var(--cp-bg);border:1px solid var(--cp-line);border-radius:20px;color:var(--cp-ink);font-size:.94rem;line-height:1.45;box-shadow:0 16px 36px -24px rgba(23,32,54,.35)}
.cp :where(p,ul){margin:0;padding:0}
.cp ul{list-style:none}
:where(.cp) button{font:inherit;color:inherit;cursor:pointer}
.cp .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}
.cp :focus-visible{outline:2px solid var(--cp-accent);outline-offset:2px}

/* short thread above each composer */
.cp-log{display:flex;flex-direction:column;gap:8px;height:160px;overflow-y:auto;padding:18px 18px 12px;background:#f8f9fc;border-bottom:1px solid var(--cp-line)}
.cp-log:focus-visible{outline-offset:-3px}
.cp-msg{display:flex;flex-direction:column;align-items:flex-start;gap:2px;max-width:80%}
.cp-msg.out{align-self:flex-end;align-items:flex-end}
.cp-who{margin-left:12px;font-size:.74rem;font-weight:600;color:var(--cp-muted)}
.cp-b{display:flex;flex-wrap:wrap;align-items:flex-end;column-gap:10px;padding:7px 12px 6px;border-radius:6px 16px 16px 16px;background:var(--cp-in);white-space:pre-wrap;overflow-wrap:anywhere}
.cp-msg.out .cp-b{border-radius:16px 6px 16px 16px;background:var(--cp-accent);color:#fff}
.cp-b time{margin-left:auto;font-size:.68rem;color:var(--cp-muted);white-space:nowrap}
.cp-msg.out .cp-b time{color:#dbe4ff}
.cp-files{width:100%;font-size:.78rem;color:#dbe4ff}
.cp-files[hidden]{display:none}

/* shared composer parts */
.cp-form{position:relative;padding:12px}
.cp-input{box-sizing:border-box;flex:1;min-width:0;max-height:150px;padding:9px 0;border:0;background:none;color:var(--cp-ink);font:inherit;line-height:1.45;resize:none}
.cp-input::placeholder{color:#626b80}
.cp-input:focus-visible{outline:none}
.cp-icon{flex:none;display:grid;place-items:center;width:38px;height:38px;border:0;border-radius:50%;background:none;color:var(--cp-muted)}
.cp-icon:hover,.cp-icon[aria-expanded="true"]{background:var(--cp-in);color:var(--cp-ink)}
.cp-send{flex:none;display:grid;place-items:center;width:38px;height:38px;border:0;border-radius:50%;background:var(--cp-accent);color:#fff;transition:background-color .15s,transform .15s}
.cp-send:hover{background:var(--cp-accent-dark)}
.cp-send:disabled{background:#dfe3ec;color:#5a6378;cursor:not-allowed}
.cp-send:not(:disabled):active{transform:scale(.94)}

/* A: pill */
.cp-pill{display:flex;align-items:flex-end;gap:4px;padding:3px 4px;border:1.5px solid var(--cp-field);border-radius:24px}
.cp-pill:focus-within{border-color:var(--cp-accent);box-shadow:0 0 0 3px var(--cp-soft)}
.cp-emoji{position:absolute;left:12px;bottom:calc(100% - 4px);display:grid;grid-template-columns:repeat(4,40px);gap:2px;padding:6px;background:var(--cp-bg);border:1px solid var(--cp-line);border-radius:14px;box-shadow:0 14px 30px -14px rgba(23,32,54,.4)}
.cp-emoji[hidden]{display:none}
.cp-emoji button{display:grid;place-items:center;width:40px;height:40px;border:0;border-radius:10px;background:none;font-size:1.3rem}
.cp-emoji button:hover{background:var(--cp-in)}

/* B: card with attachment chips */
.cp-card{border:1.5px solid var(--cp-field);border-radius:16px}
.cp-card:focus-within{border-color:var(--cp-accent);box-shadow:0 0 0 3px var(--cp-soft)}
.cp-chips{display:flex;flex-wrap:wrap;gap:6px;padding:10px 10px 0}
.cp-chips:empty{display:none}
.cp-chip{display:flex;align-items:center;gap:8px;max-width:100%;padding:5px 4px 5px 10px;border:1px solid var(--cp-line);border-radius:10px;background:#f8f9fc;font-size:.82rem}
.cp-chip svg{flex:none;color:var(--cp-accent)}
.cp-chip span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cp-chip small{flex:none;color:var(--cp-muted);font-size:.74rem}
.cp-chip button{flex:none;display:grid;place-items:center;width:24px;height:24px;border:0;border-radius:6px;background:none;color:var(--cp-muted)}
.cp-chip button:hover{background:var(--cp-line);color:var(--cp-ink)}
.cp-card-row{display:flex;align-items:flex-end;gap:4px;padding:2px 6px 6px 12px}

/* C: toolbar */
.cp-tool{border:1.5px solid var(--cp-field);border-radius:16px}
.cp-tool:focus-within{border-color:var(--cp-accent);box-shadow:0 0 0 3px var(--cp-soft)}
.cp-tool .cp-input{display:block;width:100%;min-height:68px;padding:12px 14px 4px}
.cp-bar{display:flex;align-items:center;gap:2px;padding:4px 6px 6px}
.cp-count{margin-left:auto;padding:0 10px;font-size:.78rem;font-variant-numeric:tabular-nums;color:var(--cp-muted)}
.cp-send.text{width:auto;gap:6px;display:flex;align-items:center;padding:0 14px 0 16px;border-radius:12px;font-weight:600}
.cp-hint{margin-top:8px;padding-left:4px;font-size:.78rem;color:var(--cp-muted)}
.cp-hint kbd{padding:1px 5px;border:1px solid var(--cp-line);border-bottom-width:2px;border-radius:5px;background:#f8f9fc;font:inherit;font-size:.74rem}
@media (prefers-reduced-motion:reduce){.cp-send{transition:none}.cp-send:not(:disabled):active{transform:none}}
JavaScript
/* Composers: grow the textarea, enable Send only when there is text or a file, Enter sends and Shift+Enter adds a line.
   The emoji picker and the @ button insert at the caret; files become chips you can remove. Sending only appends to the page. */
document.querySelectorAll('[data-chat]').forEach(function (chat) {
  var log = chat.querySelector('[data-log]'), form = chat.querySelector('[data-form]'), input = chat.querySelector('[data-input]');
  var send = form.querySelector('[type="submit"]'), chips = chat.querySelector('[data-chips]'), count = chat.querySelector('[data-count]');
  var status = chat.querySelector('[data-status]'), toggle = chat.querySelector('[data-emoji-toggle]'), panel = chat.querySelector('[data-emoji-panel]');
  var pad = function (n) { return String(n).padStart(2, '0'); };
  var files = function () { return chips ? Array.prototype.slice.call(chips.querySelectorAll('[data-chip]')) : []; };

  function update() {
    input.style.height = 'auto';
    input.style.height = input.scrollHeight + 'px';
    send.disabled = !input.value.trim() && !files().length;
    if (count) count.textContent = input.value.length + ' / ' + input.maxLength;
  }
  function insert(text) {
    input.focus();
    input.setRangeText(text, input.selectionStart, input.selectionEnd, 'end');
    update();
  }
  function setPanel(open) {
    panel.hidden = !open;
    toggle.setAttribute('aria-expanded', String(open));
    if (open) panel.querySelector('button').focus();
  }

  input.addEventListener('input', update);
  input.addEventListener('keydown', function (e) {
    if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); }
  });
  chat.addEventListener('click', function (e) {
    var b = e.target.closest('button');
    if (!b) return;
    if (b.hasAttribute('data-insert')) { insert(b.dataset.insert); if (panel && panel.contains(b)) setPanel(false); }
    if (b === toggle) setPanel(panel.hidden);
    if (b.hasAttribute('data-attach')) chat.querySelector('[data-file]').click();
    if (b.hasAttribute('data-remove')) {
      var chip = b.closest('[data-chip]'), next = chip.nextElementSibling || chip.previousElementSibling;
      status.textContent = 'Removed ' + chip.querySelector('[data-name]').textContent;
      chip.remove(); update();
      (next ? next.querySelector('[data-remove]') : input).focus();
    }
  });
  if (panel) chat.addEventListener('keydown', function (e) {
    if (e.key === 'Escape' && !panel.hidden) { e.preventDefault(); setPanel(false); toggle.focus(); }
  });

  var picker = chat.querySelector('[data-file]');
  if (picker) picker.addEventListener('change', function () {
    Array.prototype.forEach.call(picker.files, function (f) {
      var chip = chat.querySelector('template[data-tpl="chip"]').content.firstElementChild.cloneNode(true);
      chip.querySelector('[data-name]').textContent = f.name;
      chip.querySelector('[data-size]').textContent = f.size > 1e6 ? (f.size / 1e6).toFixed(1) + ' MB' : Math.max(1, Math.round(f.size / 1e3)) + ' KB';
      chip.querySelector('[data-remove-label]').textContent = 'Remove ' + f.name;
      chips.appendChild(chip);
    });
    status.textContent = picker.files.length + ' file' + (picker.files.length === 1 ? '' : 's') + ' attached';
    picker.value = ''; update(); input.focus();
  });

  form.addEventListener('submit', function (e) {
    e.preventDefault();
    var text = input.value.trim(), names = files().map(function (c) { return c.querySelector('[data-name]').textContent; });
    if (!text && !names.length) return;
    var d = new Date(), msg = chat.querySelector('template[data-tpl=""]').content.cloneNode(true), time = msg.querySelector('[data-time]');
    msg.querySelector('[data-text]').textContent = text;
    time.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes());
    time.dateTime = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + time.textContent;
    if (names.length) { var f = msg.querySelector('[data-files]'); f.hidden = false; f.textContent = 'Attached: ' + names.join(', '); }
    log.appendChild(msg);
    log.scrollTop = log.scrollHeight;
    files().forEach(function (c) { c.remove(); });
    input.value = ''; update(); input.focus();
  });
  log.scrollTop = log.scrollHeight;
  update();
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Pill composer with an emoji picker</span>
<div class="card cp" data-chat>
  <div class="cp-log" role="log" aria-live="polite" aria-label="Chat with Nadia Rahman" tabindex="0" data-log>
    <div class="cp-msg"><p class="cp-who">Nadia Rahman</p><p class="cp-b"><span>The sourdough starter survived the weekend.</span> <time datetime="2026-10-14T19:02">19:02</time></p></div>
    <div class="cp-msg out"><p class="cp-who visually-hidden">You</p><p class="cp-b"><span>Finally. Bake on Saturday?</span> <time datetime="2026-10-14T19:04">19:04</time></p></div>
  </div>
  <form class="cp-form" data-form>
    <div class="card cp-emoji" id="cp-emoji-a" role="group" aria-label="Emoji" hidden data-emoji-panel>
      <button class="btn cp-em" type="button" data-insert="😀" aria-label="Grinning face">😀</button><button class="btn cp-em" type="button" data-insert="😂" aria-label="Tears of joy">😂</button><button class="btn cp-em" type="button" data-insert="😍" aria-label="Heart eyes">😍</button><button class="btn cp-em" type="button" data-insert="👍" aria-label="Thumbs up">👍</button>
      <button class="btn cp-em" type="button" data-insert="🙏" aria-label="Thank you">🙏</button><button class="btn cp-em" type="button" data-insert="🎉" aria-label="Party popper">🎉</button><button class="btn cp-em" type="button" data-insert="🍞" aria-label="Bread">🍞</button><button class="btn cp-em" type="button" data-insert="❤️" aria-label="Red heart">❤️</button>
    </div>
    <div class="cp-pill d-flex align-items-end gap-1">
      <button class="btn cp-icon" type="button" aria-expanded="false" aria-controls="cp-emoji-a" data-emoji-toggle><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8.5 14.5a4.5 4.5 0 0 0 7 0M9 9.5h.01M15 9.5h.01"/></svg><span class="visually-hidden">Emoji</span></button>
      <label class="visually-hidden" for="cp-input-a">Message to Nadia</label>
      <textarea class="form-control cp-input" id="cp-input-a" rows="1" placeholder="Message" data-input></textarea>
      <button class="btn cp-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>
    </div>
  </form>
  <template data-tpl><div class="cp-msg out"><p class="cp-who visually-hidden">You</p><p class="cp-b"><span data-text></span> <time data-time></time><span class="cp-files" data-files hidden></span></p></div></template>
</div></div>

<div><span class="lbl">Attachments as removable chips</span>
<div class="card cp" data-chat>
  <div class="cp-log" role="log" aria-live="polite" aria-label="Chat with Halden Estates" tabindex="0" data-log>
    <div class="cp-msg"><p class="cp-who">Marco from Halden Estates</p><p class="cp-b"><span>Could you send the signed lease and a photo of your ID?</span> <time datetime="2026-10-14T09:40">09:40</time></p></div>
  </div>
  <form class="cp-form" data-form>
    <div class="cp-card">
      <ul class="cp-chips d-flex flex-wrap gap-2" aria-label="Attachments" data-chips><li class="cp-chip d-flex align-items-center gap-2" data-chip><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="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg><span data-name>lease-signed.pdf</span><small>840 KB</small><button class="btn cp-x" type="button" data-remove><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg><span class="visually-hidden">Remove lease-signed.pdf</span></button></li><li class="cp-chip d-flex align-items-center gap-2" data-chip><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"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M21 17l-5-5-9 8"/></svg><span data-name>id-card-front.jpg</span><small>1.9 MB</small><button class="btn cp-x" type="button" data-remove><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg><span class="visually-hidden">Remove id-card-front.jpg</span></button></li></ul>
      <div class="cp-card-row d-flex align-items-end gap-1">
        <label class="visually-hidden" for="cp-input-b">Message to Halden Estates</label>
        <textarea class="form-control cp-input" id="cp-input-b" rows="1" placeholder="Add a message" data-input></textarea>
        <button class="btn cp-icon" type="button" data-attach><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11.5 12.6 20a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8"/></svg><span class="visually-hidden">Attach files</span></button>
        <input type="file" multiple hidden data-file>
        <button class="btn cp-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>
      </div>
    </div>
  </form>
  <p class="visually-hidden" role="status" data-status></p>
  <template data-tpl><div class="cp-msg out"><p class="cp-who visually-hidden">You</p><p class="cp-b"><span data-text></span> <time data-time></time><span class="cp-files" data-files hidden></span></p></div></template>
  <template data-tpl="chip"><li class="cp-chip d-flex align-items-center gap-2" data-chip><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="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg><span data-name></span><small data-size></small><button class="btn cp-x" type="button" data-remove><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg><span class="visually-hidden" data-remove-label></span></button></li></template>
</div></div>

<div><span class="lbl">Toolbar composer with a character count and key hint</span>
<div class="card cp" data-chat>
  <div class="cp-log" role="log" aria-live="polite" aria-label="Channel kitchen-renovation" tabindex="0" data-log>
    <div class="cp-msg"><p class="cp-who">Theo Lindqvist</p><p class="cp-b"><span>Tiles arrive Thursday. @Sam can you confirm the grout colour?</span> <time datetime="2026-10-14T14:15">14:15</time></p></div>
  </div>
  <form class="cp-form" data-form>
    <div class="cp-tool">
      <label class="visually-hidden" for="cp-input-c">Message to kitchen-renovation</label>
      <textarea class="form-control cp-input" id="cp-input-c" rows="2" maxlength="500" placeholder="Write to #kitchen-renovation" aria-describedby="cp-hint-c" data-input></textarea>
      <div class="cp-bar d-flex align-items-center gap-1">
        <button class="btn cp-icon" type="button" data-insert="@"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/></svg><span class="visually-hidden">Mention someone</span></button>
        <button class="btn cp-icon" type="button" data-insert="👍" aria-label="Insert thumbs up"><span aria-hidden="true">👍</span></button>
        <span class="cp-count" data-count>0 / 500</span>
        <button class="btn cp-send text d-flex align-items-center gap-2" type="submit">Send<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></button>
      </div>
    </div>
    <p class="cp-hint mt-2" id="cp-hint-c"><kbd>Enter</kbd> to send, <kbd>Shift</kbd> + <kbd>Enter</kbd> for a new line</p>
  </form>
  <template data-tpl><div class="cp-msg out"><p class="cp-who visually-hidden">You</p><p class="cp-b"><span data-text></span> <time data-time></time><span class="cp-files" data-files hidden></span></p></div></template>
</div></div>
CSS
/* Chat composers on Bootstrap: .card, .form-control textareas and .btn controls, restyled through CSS variables.
   The textarea grows with its text, Enter sends, Shift+Enter adds a line, and Send is enabled only when there is something to send. */
.cp{--cp-accent:#1d4ed8;--cp-accent-dark:#1e40af;--cp-soft:#e3ebff;--cp-in:#eef1f7;--cp-ink:#172036;--cp-muted:#56607a;--cp-line:#dfe4ee;--cp-field:#8790a6;
  --bs-card-border-color:var(--cp-line);--bs-card-border-radius:20px;--bs-card-color:var(--cp-ink);overflow:hidden;font-size:.94rem;line-height:1.45;box-shadow:0 16px 36px -24px rgba(23,32,54,.35)}
.cp :where(p,ul){margin:0;padding:0}
.cp ul{list-style:none}
.cp :focus-visible{outline:2px solid var(--cp-accent);outline-offset:2px;box-shadow:none}
.cp .btn{--bs-btn-focus-box-shadow:none}
.cp-log{display:flex;flex-direction:column;gap:8px;height:160px;overflow-y:auto;padding:18px 18px 12px;background:#f8f9fc;border-bottom:1px solid var(--cp-line)}
.cp-log:focus-visible{outline-offset:-3px}
.cp-msg{display:flex;flex-direction:column;align-items:flex-start;gap:2px;max-width:80%}
.cp-msg.out{align-self:flex-end;align-items:flex-end}
.cp-who{margin-left:12px!important;font-size:.74rem;font-weight:600;color:var(--cp-muted)}
.cp-b{display:flex;flex-wrap:wrap;align-items:flex-end;column-gap:10px;padding:7px 12px 6px;border-radius:6px 16px 16px 16px;background:var(--cp-in);white-space:pre-wrap;overflow-wrap:anywhere}
.cp-msg.out .cp-b{border-radius:16px 6px 16px 16px;background:var(--cp-accent);color:#fff}
.cp-b time{margin-left:auto;font-size:.68rem;color:var(--cp-muted);white-space:nowrap}
.cp-msg.out .cp-b time{color:#dbe4ff}
.cp-files{width:100%;font-size:.78rem;color:#dbe4ff}
.cp-files[hidden]{display:none}
.cp-form{position:relative;padding:12px}
.cp-input{max-height:150px;padding:9px 0;border:0;border-radius:0;background:none;color:var(--cp-ink);font-size:inherit;line-height:1.45;resize:none;box-shadow:none!important}
.cp-input::placeholder{color:#626b80}
.cp-input:focus-visible{outline:none}
.cp-icon{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-width:0;--bs-btn-border-radius:50%;--bs-btn-color:var(--cp-muted);--bs-btn-hover-color:var(--cp-ink);--bs-btn-hover-bg:var(--cp-in);--bs-btn-active-color:var(--cp-ink);--bs-btn-active-bg:var(--cp-in);display:grid;place-items:center;flex:none;width:38px;height:38px}
.cp-icon[aria-expanded="true"]{background:var(--cp-in);color:var(--cp-ink)}
.cp-send{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-width:0;--bs-btn-border-radius:50%;--bs-btn-color:#fff;--bs-btn-bg:var(--cp-accent);--bs-btn-hover-color:#fff;--bs-btn-hover-bg:var(--cp-accent-dark);--bs-btn-active-color:#fff;--bs-btn-active-bg:var(--cp-accent-dark);
  --bs-btn-disabled-color:#5a6378;--bs-btn-disabled-bg:#dfe3ec;--bs-btn-disabled-opacity:1;display:grid;place-items:center;flex:none;width:38px;height:38px;transition:background-color .15s,transform .15s}
.cp-send:not(:disabled):active{transform:scale(.94)}
.cp-pill{padding:3px 4px;border:1.5px solid var(--cp-field);border-radius:24px}
.cp-pill:focus-within,.cp-card:focus-within,.cp-tool:focus-within{border-color:var(--cp-accent);box-shadow:0 0 0 3px var(--cp-soft)}
.cp-emoji{--bs-card-border-color:var(--cp-line);--bs-card-border-radius:14px;position:absolute;left:12px;bottom:calc(100% - 4px);display:grid;grid-template-columns:repeat(4,40px);gap:2px;padding:6px;box-shadow:0 14px 30px -14px rgba(23,32,54,.4)}
.cp-emoji[hidden]{display:none}
.cp-em{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-width:0;--bs-btn-border-radius:10px;--bs-btn-font-size:1.3rem;--bs-btn-hover-bg:var(--cp-in);--bs-btn-active-bg:var(--cp-in);display:grid;place-items:center;width:40px;height:40px}
.cp-card,.cp-tool{border:1.5px solid var(--cp-field);border-radius:16px}
.cp-chips{padding:10px 10px 0}
.cp-chips:empty{display:none!important}
.cp-chip{max-width:100%;padding:5px 4px 5px 10px;border:1px solid var(--cp-line);border-radius:10px;background:#f8f9fc;font-size:.82rem}
.cp-chip svg{flex:none;color:var(--cp-accent)}
.cp-chip span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.cp-chip small{flex:none;color:var(--cp-muted);font-size:.74rem}
.cp-x{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-width:0;--bs-btn-border-radius:6px;--bs-btn-color:var(--cp-muted);--bs-btn-hover-color:var(--cp-ink);--bs-btn-hover-bg:var(--cp-line);--bs-btn-active-bg:var(--cp-line);display:grid;place-items:center;flex:none;width:24px;height:24px}
.cp-card-row{padding:2px 6px 6px 12px}
.cp-tool .cp-input{min-height:68px;padding:12px 14px 4px}
.cp-bar{padding:4px 6px 6px}
.cp-count{margin-left:auto;padding:0 10px;font-size:.78rem;font-variant-numeric:tabular-nums;color:var(--cp-muted)}
.cp-send.text{--bs-btn-padding-x:14px;--bs-btn-border-radius:12px;--bs-btn-font-weight:600;--bs-btn-font-size:.94rem;width:auto;padding-left:16px}
.cp-hint{padding-left:4px;font-size:.78rem;color:var(--cp-muted)}
.cp-hint kbd{padding:1px 5px;border:1px solid var(--cp-line);border-bottom-width:2px;border-radius:5px;background:#f8f9fc;color:var(--cp-ink);font:inherit;font-size:.74rem}
@media (prefers-reduced-motion:reduce){.cp-send{transition:none}.cp-send:not(:disabled):active{transform:none}}
JavaScript
/* Composers: grow the textarea, enable Send only when there is text or a file, Enter sends and Shift+Enter adds a line.
   The emoji picker and the @ button insert at the caret; files become chips you can remove. Sending only appends to the page. */
document.querySelectorAll('[data-chat]').forEach(function (chat) {
  var log = chat.querySelector('[data-log]'), form = chat.querySelector('[data-form]'), input = chat.querySelector('[data-input]');
  var send = form.querySelector('[type="submit"]'), chips = chat.querySelector('[data-chips]'), count = chat.querySelector('[data-count]');
  var status = chat.querySelector('[data-status]'), toggle = chat.querySelector('[data-emoji-toggle]'), panel = chat.querySelector('[data-emoji-panel]');
  var pad = function (n) { return String(n).padStart(2, '0'); };
  var files = function () { return chips ? Array.prototype.slice.call(chips.querySelectorAll('[data-chip]')) : []; };

  function update() {
    input.style.height = 'auto';
    input.style.height = input.scrollHeight + 'px';
    send.disabled = !input.value.trim() && !files().length;
    if (count) count.textContent = input.value.length + ' / ' + input.maxLength;
  }
  function insert(text) {
    input.focus();
    input.setRangeText(text, input.selectionStart, input.selectionEnd, 'end');
    update();
  }
  function setPanel(open) {
    panel.hidden = !open;
    toggle.setAttribute('aria-expanded', String(open));
    if (open) panel.querySelector('button').focus();
  }

  input.addEventListener('input', update);
  input.addEventListener('keydown', function (e) {
    if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); }
  });
  chat.addEventListener('click', function (e) {
    var b = e.target.closest('button');
    if (!b) return;
    if (b.hasAttribute('data-insert')) { insert(b.dataset.insert); if (panel && panel.contains(b)) setPanel(false); }
    if (b === toggle) setPanel(panel.hidden);
    if (b.hasAttribute('data-attach')) chat.querySelector('[data-file]').click();
    if (b.hasAttribute('data-remove')) {
      var chip = b.closest('[data-chip]'), next = chip.nextElementSibling || chip.previousElementSibling;
      status.textContent = 'Removed ' + chip.querySelector('[data-name]').textContent;
      chip.remove(); update();
      (next ? next.querySelector('[data-remove]') : input).focus();
    }
  });
  if (panel) chat.addEventListener('keydown', function (e) {
    if (e.key === 'Escape' && !panel.hidden) { e.preventDefault(); setPanel(false); toggle.focus(); }
  });

  var picker = chat.querySelector('[data-file]');
  if (picker) picker.addEventListener('change', function () {
    Array.prototype.forEach.call(picker.files, function (f) {
      var chip = chat.querySelector('template[data-tpl="chip"]').content.firstElementChild.cloneNode(true);
      chip.querySelector('[data-name]').textContent = f.name;
      chip.querySelector('[data-size]').textContent = f.size > 1e6 ? (f.size / 1e6).toFixed(1) + ' MB' : Math.max(1, Math.round(f.size / 1e3)) + ' KB';
      chip.querySelector('[data-remove-label]').textContent = 'Remove ' + f.name;
      chips.appendChild(chip);
    });
    status.textContent = picker.files.length + ' file' + (picker.files.length === 1 ? '' : 's') + ' attached';
    picker.value = ''; update(); input.focus();
  });

  form.addEventListener('submit', function (e) {
    e.preventDefault();
    var text = input.value.trim(), names = files().map(function (c) { return c.querySelector('[data-name]').textContent; });
    if (!text && !names.length) return;
    var d = new Date(), msg = chat.querySelector('template[data-tpl=""]').content.cloneNode(true), time = msg.querySelector('[data-time]');
    msg.querySelector('[data-text]').textContent = text;
    time.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes());
    time.dateTime = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + time.textContent;
    if (names.length) { var f = msg.querySelector('[data-files]'); f.hidden = false; f.textContent = 'Attached: ' + names.join(', '); }
    log.appendChild(msg);
    log.scrollTop = log.scrollHeight;
    files().forEach(function (c) { c.remove(); });
    input.value = ''; update(); input.focus();
  });
  log.scrollTop = log.scrollHeight;
  update();
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-cp-muted uppercase">Pill composer with an emoji picker</span>
<div class="overflow-hidden rounded-[20px] border border-cp-line bg-white text-[.94rem] leading-[1.45] text-cp-ink shadow-[0_16px_36px_-24px_rgba(23,32,54,.35)]" data-chat>
  <div class="flex h-40 flex-col gap-2 overflow-y-auto border-b border-cp-line bg-[#f8f9fc] px-[18px] pt-[18px] pb-3 focus-visible:outline-2 focus-visible:-outline-offset-[3px] focus-visible:outline-cp-accent" role="log" aria-live="polite" aria-label="Chat with Nadia Rahman" tabindex="0" data-log>
    <div class="flex max-w-[80%] flex-col items-start gap-0.5"><p class="ml-3 text-[.74rem] font-semibold text-cp-muted">Nadia Rahman</p><p class="flex flex-wrap items-end gap-x-2.5 rounded-[6px_16px_16px_16px] bg-cp-in px-3 pt-[7px] pb-1.5 whitespace-pre-wrap [overflow-wrap:anywhere]"><span>The sourdough starter survived the weekend.</span> <time class="ml-auto text-[.68rem] whitespace-nowrap text-cp-muted" datetime="2026-10-14T19:02">19:02</time></p></div>
    <div class="flex max-w-[80%] flex-col items-end gap-0.5 self-end"><p class="sr-only">You</p><p class="flex flex-wrap items-end gap-x-2.5 rounded-[16px_6px_16px_16px] bg-cp-accent text-white px-3 pt-[7px] pb-1.5 whitespace-pre-wrap [overflow-wrap:anywhere]"><span>Finally. Bake on Saturday?</span> <time class="ml-auto text-[.68rem] whitespace-nowrap text-[#dbe4ff]" datetime="2026-10-14T19:04">19:04</time></p></div>
  </div>
  <form class="relative p-3" data-form>
    <div class="absolute bottom-[calc(100%-4px)] left-3 grid grid-cols-[repeat(4,40px)] gap-0.5 rounded-[14px] border border-cp-line bg-white p-1.5 shadow-[0_14px_30px_-14px_rgba(23,32,54,.4)]" id="cp-emoji-a" role="group" aria-label="Emoji" hidden data-emoji-panel>
      <button class="grid size-10 cursor-pointer place-items-center rounded-[10px] text-[1.3rem] hover:bg-cp-in focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" data-insert="😀" aria-label="Grinning face">😀</button><button class="grid size-10 cursor-pointer place-items-center rounded-[10px] text-[1.3rem] hover:bg-cp-in focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" data-insert="😂" aria-label="Tears of joy">😂</button><button class="grid size-10 cursor-pointer place-items-center rounded-[10px] text-[1.3rem] hover:bg-cp-in focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" data-insert="😍" aria-label="Heart eyes">😍</button><button class="grid size-10 cursor-pointer place-items-center rounded-[10px] text-[1.3rem] hover:bg-cp-in focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" data-insert="👍" aria-label="Thumbs up">👍</button>
      <button class="grid size-10 cursor-pointer place-items-center rounded-[10px] text-[1.3rem] hover:bg-cp-in focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" data-insert="🙏" aria-label="Thank you">🙏</button><button class="grid size-10 cursor-pointer place-items-center rounded-[10px] text-[1.3rem] hover:bg-cp-in focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" data-insert="🎉" aria-label="Party popper">🎉</button><button class="grid size-10 cursor-pointer place-items-center rounded-[10px] text-[1.3rem] hover:bg-cp-in focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" data-insert="🍞" aria-label="Bread">🍞</button><button class="grid size-10 cursor-pointer place-items-center rounded-[10px] text-[1.3rem] hover:bg-cp-in focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" data-insert="❤️" aria-label="Red heart">❤️</button>
    </div>
    <div class="flex items-end gap-1 rounded-[24px] border-[1.5px] border-cp-field px-1 py-[3px] focus-within:border-cp-accent focus-within:shadow-[0_0_0_3px_var(--color-cp-soft)]">
      <button class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-full text-cp-muted hover:bg-cp-in hover:text-cp-ink aria-expanded:bg-cp-in aria-expanded:text-cp-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" aria-expanded="false" aria-controls="cp-emoji-a" data-emoji-toggle><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M8.5 14.5a4.5 4.5 0 0 0 7 0M9 9.5h.01M15 9.5h.01"/></svg><span class="sr-only">Emoji</span></button>
      <label class="sr-only" for="cp-input-a">Message to Nadia</label>
      <textarea class="box-border min-w-0 flex-1 max-h-[150px] resize-none bg-transparent py-[9px] leading-[1.45] text-cp-ink placeholder:text-[#626b80] focus-visible:outline-none" id="cp-input-a" rows="1" placeholder="Message" data-input></textarea>
      <button class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-full bg-cp-accent text-white transition-[background-color,transform] duration-150 hover:bg-cp-accent-dark enabled:active:scale-[.94] disabled:cursor-not-allowed disabled:bg-[#dfe3ec] disabled:text-[#5a6378] motion-reduce:transition-none motion-reduce:enabled:active:scale-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-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>
    </div>
  </form>
  <template data-tpl><div class="flex max-w-[80%] flex-col items-end gap-0.5 self-end"><p class="sr-only">You</p><p class="flex flex-wrap items-end gap-x-2.5 rounded-[16px_6px_16px_16px] bg-cp-accent text-white px-3 pt-[7px] pb-1.5 whitespace-pre-wrap [overflow-wrap:anywhere]"><span data-text></span> <time class="ml-auto text-[.68rem] whitespace-nowrap text-[#dbe4ff]" data-time></time><span class="w-full text-[.78rem] text-[#dbe4ff]" data-files hidden></span></p></div></template>
</div></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-cp-muted uppercase">Attachments as removable chips</span>
<div class="overflow-hidden rounded-[20px] border border-cp-line bg-white text-[.94rem] leading-[1.45] text-cp-ink shadow-[0_16px_36px_-24px_rgba(23,32,54,.35)]" data-chat>
  <div class="flex h-40 flex-col gap-2 overflow-y-auto border-b border-cp-line bg-[#f8f9fc] px-[18px] pt-[18px] pb-3 focus-visible:outline-2 focus-visible:-outline-offset-[3px] focus-visible:outline-cp-accent" role="log" aria-live="polite" aria-label="Chat with Halden Estates" tabindex="0" data-log>
    <div class="flex max-w-[80%] flex-col items-start gap-0.5"><p class="ml-3 text-[.74rem] font-semibold text-cp-muted">Marco from Halden Estates</p><p class="flex flex-wrap items-end gap-x-2.5 rounded-[6px_16px_16px_16px] bg-cp-in px-3 pt-[7px] pb-1.5 whitespace-pre-wrap [overflow-wrap:anywhere]"><span>Could you send the signed lease and a photo of your ID?</span> <time class="ml-auto text-[.68rem] whitespace-nowrap text-cp-muted" datetime="2026-10-14T09:40">09:40</time></p></div>
  </div>
  <form class="relative p-3" data-form>
    <div class="rounded-2xl border-[1.5px] border-cp-field focus-within:border-cp-accent focus-within:shadow-[0_0_0_3px_var(--color-cp-soft)]">
      <ul class="flex flex-wrap gap-1.5 px-2.5 pt-2.5 empty:hidden" aria-label="Attachments" data-chips><li class="flex max-w-full items-center gap-2 rounded-[10px] border border-cp-line bg-[#f8f9fc] py-[5px] pr-1 pl-2.5 text-[.82rem] [&>svg]:flex-none [&>svg]:text-cp-accent" data-chip><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="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg><span class="truncate" data-name>lease-signed.pdf</span><small class="flex-none text-[.74rem] text-cp-muted">840 KB</small><button class="grid size-6 flex-none cursor-pointer place-items-center rounded-md text-cp-muted hover:bg-cp-line hover:text-cp-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" data-remove><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg><span class="sr-only">Remove lease-signed.pdf</span></button></li><li class="flex max-w-full items-center gap-2 rounded-[10px] border border-cp-line bg-[#f8f9fc] py-[5px] pr-1 pl-2.5 text-[.82rem] [&>svg]:flex-none [&>svg]:text-cp-accent" data-chip><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"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="2"/><path d="M21 17l-5-5-9 8"/></svg><span class="truncate" data-name>id-card-front.jpg</span><small class="flex-none text-[.74rem] text-cp-muted">1.9 MB</small><button class="grid size-6 flex-none cursor-pointer place-items-center rounded-md text-cp-muted hover:bg-cp-line hover:text-cp-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" data-remove><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg><span class="sr-only">Remove id-card-front.jpg</span></button></li></ul>
      <div class="flex items-end gap-1 pt-0.5 pr-1.5 pb-1.5 pl-3">
        <label class="sr-only" for="cp-input-b">Message to Halden Estates</label>
        <textarea class="box-border min-w-0 flex-1 max-h-[150px] resize-none bg-transparent py-[9px] leading-[1.45] text-cp-ink placeholder:text-[#626b80] focus-visible:outline-none" id="cp-input-b" rows="1" placeholder="Add a message" data-input></textarea>
        <button class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-full text-cp-muted hover:bg-cp-in hover:text-cp-ink aria-expanded:bg-cp-in aria-expanded:text-cp-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" data-attach><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 11.5 12.6 20a5 5 0 0 1-7.1-7.1l8.5-8.5a3.3 3.3 0 0 1 4.7 4.7l-8.5 8.5a1.7 1.7 0 0 1-2.4-2.4l7.8-7.8"/></svg><span class="sr-only">Attach files</span></button>
        <input type="file" multiple hidden data-file>
        <button class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-full bg-cp-accent text-white transition-[background-color,transform] duration-150 hover:bg-cp-accent-dark enabled:active:scale-[.94] disabled:cursor-not-allowed disabled:bg-[#dfe3ec] disabled:text-[#5a6378] motion-reduce:transition-none motion-reduce:enabled:active:scale-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-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>
      </div>
    </div>
  </form>
  <p class="sr-only" role="status" data-status></p>
  <template data-tpl><div class="flex max-w-[80%] flex-col items-end gap-0.5 self-end"><p class="sr-only">You</p><p class="flex flex-wrap items-end gap-x-2.5 rounded-[16px_6px_16px_16px] bg-cp-accent text-white px-3 pt-[7px] pb-1.5 whitespace-pre-wrap [overflow-wrap:anywhere]"><span data-text></span> <time class="ml-auto text-[.68rem] whitespace-nowrap text-[#dbe4ff]" data-time></time><span class="w-full text-[.78rem] text-[#dbe4ff]" data-files hidden></span></p></div></template>
  <template data-tpl="chip"><li class="flex max-w-full items-center gap-2 rounded-[10px] border border-cp-line bg-[#f8f9fc] py-[5px] pr-1 pl-2.5 text-[.82rem] [&>svg]:flex-none [&>svg]:text-cp-accent" data-chip><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="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/></svg><span class="truncate" data-name></span><small class="flex-none text-[.74rem] text-cp-muted" data-size></small><button class="grid size-6 flex-none cursor-pointer place-items-center rounded-md text-cp-muted hover:bg-cp-line hover:text-cp-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" data-remove><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6 6 18"/></svg><span class="sr-only" data-remove-label></span></button></li></template>
</div></div>

<div><span class="mb-4 block text-[.72rem] tracking-[.12em] text-cp-muted uppercase">Toolbar composer with a character count and key hint</span>
<div class="overflow-hidden rounded-[20px] border border-cp-line bg-white text-[.94rem] leading-[1.45] text-cp-ink shadow-[0_16px_36px_-24px_rgba(23,32,54,.35)]" data-chat>
  <div class="flex h-40 flex-col gap-2 overflow-y-auto border-b border-cp-line bg-[#f8f9fc] px-[18px] pt-[18px] pb-3 focus-visible:outline-2 focus-visible:-outline-offset-[3px] focus-visible:outline-cp-accent" role="log" aria-live="polite" aria-label="Channel kitchen-renovation" tabindex="0" data-log>
    <div class="flex max-w-[80%] flex-col items-start gap-0.5"><p class="ml-3 text-[.74rem] font-semibold text-cp-muted">Theo Lindqvist</p><p class="flex flex-wrap items-end gap-x-2.5 rounded-[6px_16px_16px_16px] bg-cp-in px-3 pt-[7px] pb-1.5 whitespace-pre-wrap [overflow-wrap:anywhere]"><span>Tiles arrive Thursday. @Sam can you confirm the grout colour?</span> <time class="ml-auto text-[.68rem] whitespace-nowrap text-cp-muted" datetime="2026-10-14T14:15">14:15</time></p></div>
  </div>
  <form class="relative p-3" data-form>
    <div class="rounded-2xl border-[1.5px] border-cp-field focus-within:border-cp-accent focus-within:shadow-[0_0_0_3px_var(--color-cp-soft)]">
      <label class="sr-only" for="cp-input-c">Message to kitchen-renovation</label>
      <textarea class="block w-full box-border min-h-[68px] max-h-[150px] resize-none bg-transparent px-3.5 pt-3 pb-1 leading-[1.45] text-cp-ink placeholder:text-[#626b80] focus-visible:outline-none" id="cp-input-c" rows="2" maxlength="500" placeholder="Write to #kitchen-renovation" aria-describedby="cp-hint-c" data-input></textarea>
      <div class="flex items-center gap-0.5 px-1.5 pt-1 pb-1.5">
        <button class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-full text-cp-muted hover:bg-cp-in hover:text-cp-ink aria-expanded:bg-cp-in aria-expanded:text-cp-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" data-insert="@"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-4 8"/></svg><span class="sr-only">Mention someone</span></button>
        <button class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-full text-cp-muted hover:bg-cp-in hover:text-cp-ink aria-expanded:bg-cp-in aria-expanded:text-cp-ink focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="button" data-insert="👍" aria-label="Insert thumbs up"><span aria-hidden="true">👍</span></button>
        <span class="ml-auto px-2.5 text-[.78rem] text-cp-muted tabular-nums" data-count>0 / 500</span>
        <button class="flex h-[38px] flex-none cursor-pointer items-center gap-1.5 rounded-xl bg-cp-accent pr-3.5 pl-4 font-semibold text-white transition-[background-color,transform] duration-150 hover:bg-cp-accent-dark enabled:active:scale-[.94] disabled:cursor-not-allowed disabled:bg-[#dfe3ec] disabled:text-[#5a6378] motion-reduce:transition-none motion-reduce:enabled:active:scale-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cp-accent" type="submit">Send<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></button>
      </div>
    </div>
    <p class="mt-2 pl-1 text-[.78rem] text-cp-muted" id="cp-hint-c"><kbd class="[font:inherit] rounded-[5px] border border-b-2 border-cp-line bg-[#f8f9fc] px-[5px] py-px text-[.74rem]">Enter</kbd> to send, <kbd class="[font:inherit] rounded-[5px] border border-b-2 border-cp-line bg-[#f8f9fc] px-[5px] py-px text-[.74rem]">Shift</kbd> + <kbd class="[font:inherit] rounded-[5px] border border-b-2 border-cp-line bg-[#f8f9fc] px-[5px] py-px text-[.74rem]">Enter</kbd> for a new line</p>
  </form>
  <template data-tpl><div class="flex max-w-[80%] flex-col items-end gap-0.5 self-end"><p class="sr-only">You</p><p class="flex flex-wrap items-end gap-x-2.5 rounded-[16px_6px_16px_16px] bg-cp-accent text-white px-3 pt-[7px] pb-1.5 whitespace-pre-wrap [overflow-wrap:anywhere]"><span data-text></span> <time class="ml-auto text-[.68rem] whitespace-nowrap text-[#dbe4ff]" data-time></time><span class="w-full text-[.78rem] text-[#dbe4ff]" data-files hidden></span></p></div></template>
</div></div>
CSS
/* Composer palette as theme colours. */
@theme {
  --color-cp-accent:#1d4ed8; --color-cp-accent-dark:#1e40af; --color-cp-soft:#e3ebff; --color-cp-in:#eef1f7;
  --color-cp-ink:#172036; --color-cp-muted:#56607a; --color-cp-line:#dfe4ee; --color-cp-field:#8790a6;
}
JavaScript
/* Composers: grow the textarea, enable Send only when there is text or a file, Enter sends and Shift+Enter adds a line.
   The emoji picker and the @ button insert at the caret; files become chips you can remove. Sending only appends to the page. */
document.querySelectorAll('[data-chat]').forEach(function (chat) {
  var log = chat.querySelector('[data-log]'), form = chat.querySelector('[data-form]'), input = chat.querySelector('[data-input]');
  var send = form.querySelector('[type="submit"]'), chips = chat.querySelector('[data-chips]'), count = chat.querySelector('[data-count]');
  var status = chat.querySelector('[data-status]'), toggle = chat.querySelector('[data-emoji-toggle]'), panel = chat.querySelector('[data-emoji-panel]');
  var pad = function (n) { return String(n).padStart(2, '0'); };
  var files = function () { return chips ? Array.prototype.slice.call(chips.querySelectorAll('[data-chip]')) : []; };

  function update() {
    input.style.height = 'auto';
    input.style.height = input.scrollHeight + 'px';
    send.disabled = !input.value.trim() && !files().length;
    if (count) count.textContent = input.value.length + ' / ' + input.maxLength;
  }
  function insert(text) {
    input.focus();
    input.setRangeText(text, input.selectionStart, input.selectionEnd, 'end');
    update();
  }
  function setPanel(open) {
    panel.hidden = !open;
    toggle.setAttribute('aria-expanded', String(open));
    if (open) panel.querySelector('button').focus();
  }

  input.addEventListener('input', update);
  input.addEventListener('keydown', function (e) {
    if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { e.preventDefault(); form.requestSubmit(); }
  });
  chat.addEventListener('click', function (e) {
    var b = e.target.closest('button');
    if (!b) return;
    if (b.hasAttribute('data-insert')) { insert(b.dataset.insert); if (panel && panel.contains(b)) setPanel(false); }
    if (b === toggle) setPanel(panel.hidden);
    if (b.hasAttribute('data-attach')) chat.querySelector('[data-file]').click();
    if (b.hasAttribute('data-remove')) {
      var chip = b.closest('[data-chip]'), next = chip.nextElementSibling || chip.previousElementSibling;
      status.textContent = 'Removed ' + chip.querySelector('[data-name]').textContent;
      chip.remove(); update();
      (next ? next.querySelector('[data-remove]') : input).focus();
    }
  });
  if (panel) chat.addEventListener('keydown', function (e) {
    if (e.key === 'Escape' && !panel.hidden) { e.preventDefault(); setPanel(false); toggle.focus(); }
  });

  var picker = chat.querySelector('[data-file]');
  if (picker) picker.addEventListener('change', function () {
    Array.prototype.forEach.call(picker.files, function (f) {
      var chip = chat.querySelector('template[data-tpl="chip"]').content.firstElementChild.cloneNode(true);
      chip.querySelector('[data-name]').textContent = f.name;
      chip.querySelector('[data-size]').textContent = f.size > 1e6 ? (f.size / 1e6).toFixed(1) + ' MB' : Math.max(1, Math.round(f.size / 1e3)) + ' KB';
      chip.querySelector('[data-remove-label]').textContent = 'Remove ' + f.name;
      chips.appendChild(chip);
    });
    status.textContent = picker.files.length + ' file' + (picker.files.length === 1 ? '' : 's') + ' attached';
    picker.value = ''; update(); input.focus();
  });

  form.addEventListener('submit', function (e) {
    e.preventDefault();
    var text = input.value.trim(), names = files().map(function (c) { return c.querySelector('[data-name]').textContent; });
    if (!text && !names.length) return;
    var d = new Date(), msg = chat.querySelector('template[data-tpl=""]').content.cloneNode(true), time = msg.querySelector('[data-time]');
    msg.querySelector('[data-text]').textContent = text;
    time.textContent = pad(d.getHours()) + ':' + pad(d.getMinutes());
    time.dateTime = d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + time.textContent;
    if (names.length) { var f = msg.querySelector('[data-files]'); f.hidden = false; f.textContent = 'Attached: ' + names.join(', '); }
    log.appendChild(msg);
    log.scrollTop = log.scrollHeight;
    files().forEach(function (c) { c.remove(); });
    input.value = ''; update(); input.focus();
  });
  log.scrollTop = log.scrollHeight;
  update();
});

About this set

Three message composers with the behaviour that makes a chat input feel right. The pill composer has an emoji button that opens a grid of eight emoji and inserts the chosen one at the caret. The attachment composer shows files as removable chips with a name and size; the paperclip opens the file picker, each chip has a Remove button named after its file, and Send stays enabled when there are files even without text. The toolbar composer is a two-line card with a mention button, a quick thumbs-up, a character count against a maxlength of 500 and a visible hint that Enter sends and Shift+Enter starts a new line. All three grow with their text up to a maximum height, keep Send disabled while there is nothing to send and append the message to a short thread above, including the names of attached files. One script of about 80 lines drives all three through data attributes, and it manages focus for you: Esc returns it to the emoji button and removing a chip moves it to the next chip.

What’s included

  • Growing textarea with a maximum height
  • Enter sends, Shift+Enter adds a line, safe for IME input
  • Send disabled until there is text or a file
  • Emoji grid and @ button that insert at the caret
  • Removable attachment chips from a real file input
  • Character count against maxlength with a key hint

Accessibility

Every textarea has a label, every icon button has hidden text, and the emoji buttons are named, for example Grinning face. The emoji toggle uses aria-expanded and aria-controls, Esc closes the grid and returns focus to it, and removing a chip moves focus to the next chip and announces the removal through a role="status" line. The key hint is linked to its textarea with aria-describedby, and the press effect on Send is removed under prefers-reduced-motion.

Customise it

Change --cp-accent, --cp-in and --cp-field on .cp, and max-height on .cp-input to cap the growth. In Tailwind edit the --color-cp-* theme colours and the max-h-[150px] utility; in Bootstrap the same variables sit on the .card.