Open demo

CSS code

HTML
<div><span class="lbl">Email composer attachments</span>
<div class="att att-mail" data-att>
  <div class="att-field"><label for="att-to">To</label><input id="att-to" type="email" value="[email protected]" autocomplete="off"></div>
  <div class="att-field"><label for="att-subject">Subject</label><input id="att-subject" type="text" value="Crew list and permits for 14 October"></div>
  <textarea class="att-body" aria-label="Message">Hi Lena, attached are the signed crew list and the harbour permit for the October charter. The insurance certificate follows tomorrow.</textarea>
  <ul class="att-chips" aria-label="Attachments" data-att-list></ul>
  <p class="att-error" id="att-mail-error" hidden></p>
  <div class="att-bar">
    <input class="att-sr" type="file" id="att-mail" multiple data-max-total="26214400" aria-describedby="att-mail-total att-mail-error">
    <label class="att-tool" for="att-mail"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg><span class="att-sr">Attach files</span></label>
    <p class="att-total" id="att-mail-total" data-att-total>Up to 25 MB in total</p>
    <button class="att-send" type="button">Send<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4Z"/></svg></button>
  </div>
  <p class="att-sr" aria-live="polite" data-att-live></p>
  <template><li class="att-chip"><svg class="doc" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg><span class="att-name" data-name></span><span class="att-size" data-size></span><button class="att-x" type="button" data-remove><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button></li></template>
</div></div>

<div><span class="lbl">Chat message bar with previews</span>
<div class="att att-chat" data-att>
  <ul class="att-chips" aria-label="Attachments" data-att-list></ul>
  <p class="att-error" id="att-chat-error" hidden></p>
  <div class="att-row">
    <input class="att-sr" type="file" id="att-chat" multiple accept="image/*,.pdf" data-max-size="10485760" data-max-files="4" data-types="image or PDF" aria-describedby="att-chat-error">
    <label class="att-tool" for="att-chat"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 5v14M5 12h14"/></svg><span class="att-sr">Add photos or PDFs</span></label>
    <label class="att-sr" for="att-chat-msg">Message</label>
    <input class="att-msg" id="att-chat-msg" type="text" placeholder="Message #site-visits" autocomplete="off">
    <button class="att-send" type="button" aria-label="Send message"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
  </div>
  <p class="att-sr" aria-live="polite" data-att-live></p>
  <template><li class="att-thumb"><img alt="" data-thumb><span data-ext></span><button class="att-x" type="button" data-remove><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button></li></template>
</div></div>
CSS
/* Inline attachments for an email or chat composer. A paperclip <label> opens the real file input; files can also be
   dropped anywhere on the composer. Each attachment is a chip with a named remove button, and the total is checked. */
.att{--att-accent:#3b5bdb;--att-accent-ink:#2f4ac0;--att-tint:#eef2ff;--att-ink:#0f172a;--att-muted:#5b6474;--att-line:#d5dbe5;
  --att-danger:#b42318;position:relative;color:var(--att-ink)}
.att-sr{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
.att[data-drag]::after{content:"Drop files to attach";position:absolute;inset:0;z-index:5;display:grid;place-items:center;font-weight:700;color:var(--att-accent-ink);
  background:rgba(238,242,255,.94);border:2px dashed var(--att-accent);border-radius:inherit;pointer-events:none}
/* email composer */
.att-mail{display:grid;background:#fff;border:1px solid var(--att-line);border-radius:16px;box-shadow:0 12px 32px -16px rgba(15,23,42,.25)}
.att-field{display:flex;align-items:center;gap:10px;padding:10px 18px;border-bottom:1px solid #eef1f6;font-size:.9rem}
.att-field label{flex:none;width:58px;color:var(--att-muted)}
.att-field input{flex:1;min-width:0;padding:4px 0;font:inherit;color:inherit;background:none;border:0}
.att-field input:focus-visible,.att-body:focus-visible{outline:2px solid var(--att-accent);outline-offset:2px;border-radius:4px}
.att-body{display:block;width:100%;min-height:120px;padding:14px 18px;font:inherit;font-size:.92rem;line-height:1.6;color:inherit;background:none;border:0;resize:vertical}
.att-chips{display:flex;flex-wrap:wrap;gap:8px;margin:0;padding:0 18px 12px;list-style:none}
.att-chips:empty{display:none}
.att-chip{display:flex;align-items:center;gap:8px;max-width:100%;padding:5px 5px 5px 10px;font-size:.82rem;background:#f8fafc;border:1px solid var(--att-line);border-radius:10px}
.att-chip svg.doc{width:16px;height:16px;flex:none;color:var(--att-accent)}
.att-name{min-width:0;max-width:190px;overflow:hidden;font-weight:600;text-overflow:ellipsis;white-space:nowrap}
.att-size{flex:none;color:var(--att-muted)}
.att-x{display:grid;place-items:center;flex:none;width:26px;height:26px;padding:0;color:#475467;background:none;border:0;border-radius:7px;cursor:pointer}
.att-x:hover{color:var(--att-danger);background:#fef3f2}
.att-x:focus-visible{outline:2px solid var(--att-accent);outline-offset:1px}
.att-x svg{width:14px;height:14px}
.att-bar{display:flex;align-items:center;gap:6px;padding:10px 12px 10px 18px;border-top:1px solid #eef1f6}
.att-total{margin:0 auto 0 6px;font-size:.8rem;color:var(--att-muted)}
.att-tool{display:grid;place-items:center;flex:none;width:38px;height:38px;color:#344054;border-radius:10px;cursor:pointer;transition:background-color .15s}
.att-tool:hover{background:#f1f5f9;color:var(--att-ink)}
.att-tool svg{width:20px;height:20px}
.att-sr:focus-visible + .att-tool{outline:3px solid var(--att-accent);outline-offset:1px}
.att-send{display:inline-flex;align-items:center;gap:8px;padding:9px 16px;font:inherit;font-size:.88rem;font-weight:600;color:#fff;background:var(--att-accent);
  border:0;border-radius:10px;cursor:pointer;transition:background-color .15s}
.att-send:hover{background:var(--att-accent-ink)}
.att-send:focus-visible{outline:3px solid var(--att-accent);outline-offset:2px}
.att-send svg{width:16px;height:16px}
.att-error{margin:0;padding:0 18px 10px;font-size:.82rem;font-weight:600;color:var(--att-danger)}
.att-error[hidden]{display:none}
/* chat bar */
.att-chat{display:grid;gap:8px;padding:10px;background:#fff;border:1px solid var(--att-line);border-radius:22px;box-shadow:0 12px 32px -16px rgba(15,23,42,.25)}
.att-chat .att-chips{padding:4px 4px 0}
.att-chat .att-error{padding:0 8px}
.att-thumb{position:relative;width:64px;height:64px;border-radius:12px;overflow:hidden;background:#e2e8f0}
.att-thumb img{display:block;width:100%;height:100%;object-fit:cover}
.att-thumb .att-x{position:absolute;top:4px;right:4px;width:22px;height:22px;color:#fff;background:rgba(15,23,42,.78);border-radius:50%}
.att-thumb .att-x:hover{background:var(--att-danger);color:#fff}
.att-thumb .att-x:focus-visible{outline-color:#fff;box-shadow:0 0 0 4px var(--att-accent)}
.att-thumb[data-kind="file"]{display:grid;place-items:center;align-content:center;gap:2px;padding:6px;background:var(--att-tint);color:var(--att-accent-ink);
  font-size:.62rem;font-weight:800;letter-spacing:.04em}
.att-thumb[data-kind="file"] img{display:none}
.att-row{display:flex;align-items:center;gap:6px}
.att-row .att-tool{border-radius:50%}
.att-msg{flex:1;min-width:0;height:40px;padding:0 6px;font:inherit;font-size:.92rem;color:inherit;background:none;border:0}
.att-msg:focus-visible{outline:2px solid var(--att-accent);outline-offset:2px;border-radius:6px}
.att-msg::placeholder{color:var(--att-muted);opacity:1}
.att-row .att-send{width:40px;height:40px;padding:0;justify-content:center;border-radius:50%}
@media (prefers-reduced-motion:reduce){.att-tool,.att-send{transition:none}}
JavaScript
// Inline attachments: files come from the paperclip input or a drop anywhere on the composer. Each is checked
// (type, size, count, total), shown as a chip or thumbnail, and input.files is kept in step with DataTransfer.
document.querySelectorAll('[data-att]').forEach(box => {
  const input = box.querySelector('input[type="file"]');
  const list = box.querySelector('[data-att-list]');
  const tpl = box.querySelector('template');
  const live = box.querySelector('[data-att-live]');
  const total = box.querySelector('[data-att-total]');
  const error = document.getElementById(input.id + '-error');
  const maxSize = Number(input.dataset.maxSize) || Infinity, maxTotal = Number(input.dataset.maxTotal) || Infinity;
  const maxFiles = Number(input.dataset.maxFiles) || Infinity;
  const accept = input.accept.split(',').map(a => a.trim().toLowerCase()).filter(Boolean);
  const urls = new Map();
  let files = [];
  const fmt = b => b < 1048576 ? Math.max(1, Math.round(b / 1024)) + ' KB' : (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
  const sum = () => files.reduce((n, f) => n + f.size, 0);
  const typeOk = f => !accept.length || accept.some(a => a[0] === '.' ? f.name.toLowerCase().endsWith(a) : a.endsWith('/*') ? f.type.startsWith(a.slice(0, -1)) : f.type === a);

  function render() {
    list.replaceChildren(...files.map(f => {
      const li = tpl.content.firstElementChild.cloneNode(true), img = li.querySelector('[data-thumb]');
      const name = li.querySelector('[data-name]'), size = li.querySelector('[data-size]'), ext = li.querySelector('[data-ext]');
      if (name) name.textContent = f.name;
      if (size) size.textContent = fmt(f.size);
      if (img && f.type.startsWith('image/')) { if (!urls.has(f)) urls.set(f, URL.createObjectURL(f)); img.src = urls.get(f); img.alt = f.name; }
      else { li.dataset.kind = 'file'; if (ext) ext.textContent = (f.name.split('.').pop() || 'file').slice(0, 4).toUpperCase(); }
      const btn = li.querySelector('[data-remove]');
      btn.setAttribute('aria-label', 'Remove ' + f.name);
      btn.addEventListener('click', () => remove(f));
      return li;
    }));
    if (total) total.textContent = files.length ? files.length + (files.length === 1 ? ' file' : ' files') + ' · ' + fmt(sum()) + ' of ' + fmt(maxTotal) : 'Up to ' + fmt(maxTotal) + ' in total';
    const dt = new DataTransfer(); files.forEach(f => dt.items.add(f)); input.files = dt.files;
  }
  function remove(f) {
    const i = files.indexOf(f);
    files.splice(i, 1); URL.revokeObjectURL(urls.get(f)); urls.delete(f); render();
    live.textContent = 'Removed ' + f.name + '.';
    const next = list.querySelectorAll('[data-remove]')[Math.min(i, files.length - 1)];
    (next || input).focus();
  }
  function add(picked) {
    const added = [], problems = [];
    for (const f of picked) {
      if (files.some(g => g.name === f.name && g.size === f.size)) continue;
      if (!typeOk(f)) problems.push(f.name + ' can’t be attached: only ' + input.dataset.types + ' files are allowed.');
      else if (f.size > maxSize) problems.push(f.name + ' is ' + fmt(f.size) + '; each file must be under ' + fmt(maxSize) + '.');
      else if (sum() + f.size > maxTotal) problems.push(f.name + ' would take the total over ' + fmt(maxTotal) + '.');
      else if (files.length >= maxFiles) problems.push(f.name + ' wasn’t added: up to ' + maxFiles + ' files per message.');
      else { files.push(f); added.push(f.name); }
    }
    render();
    error.textContent = problems.join(' ');
    error.hidden = !problems.length;
    live.textContent = (added.length ? 'Attached ' + added.join(', ') + '. ' : '') + problems.join(' ');
  }
  input.addEventListener('change', () => add([...input.files]));
  box.addEventListener('dragover', e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); box.setAttribute('data-drag', ''); } });
  box.addEventListener('dragleave', e => { if (!box.contains(e.relatedTarget)) box.removeAttribute('data-drag'); });
  box.addEventListener('drop', e => { if (!e.dataTransfer.files.length) return; e.preventDefault(); box.removeAttribute('data-drag'); add([...e.dataTransfer.files]); });
});

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Email composer attachments</span>
<div class="att att-mail card position-relative" data-att>
  <div class="att-field d-flex align-items-center gap-2"><label class="flex-shrink-0" for="att-to">To</label><input class="form-control" id="att-to" type="email" value="[email protected]" autocomplete="off"></div>
  <div class="att-field d-flex align-items-center gap-2"><label class="flex-shrink-0" for="att-subject">Subject</label><input class="form-control" id="att-subject" type="text" value="Crew list and permits for 14 October"></div>
  <textarea class="form-control" aria-label="Message">Hi Lena, attached are the signed crew list and the harbour permit for the October charter. The insurance certificate follows tomorrow.</textarea>
  <ul class="att-chips list-unstyled d-flex flex-wrap gap-2 px-3 pb-2 mb-1 mx-1" aria-label="Attachments" data-att-list></ul>
  <p class="att-error px-3 mx-1 mb-2" id="att-mail-error" hidden></p>
  <div class="att-bar d-flex align-items-center gap-1">
    <input class="visually-hidden" type="file" id="att-mail" multiple data-max-total="26214400" aria-describedby="att-mail-total att-mail-error">
    <label class="att-tool d-grid flex-shrink-0 align-items-center justify-content-center" for="att-mail"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg><span class="visually-hidden">Attach files</span></label>
    <p class="att-total mb-0 me-auto ms-1" id="att-mail-total" data-att-total>Up to 25 MB in total</p>
    <button class="btn btn-primary d-inline-flex align-items-center gap-2" type="button">Send<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4Z"/></svg></button>
  </div>
  <p class="visually-hidden" aria-live="polite" data-att-live></p>
  <template><li class="att-chip d-flex align-items-center gap-2"><svg class="flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg><span class="att-name fw-semibold text-truncate" data-name></span><span class="att-size flex-shrink-0" data-size></span><button class="btn-close flex-shrink-0" type="button" data-remove></button></li></template>
</div></div>

<div><span class="lbl">Chat message bar with previews</span>
<div class="att att-chat card position-relative d-grid gap-2" data-att>
  <ul class="att-chips list-unstyled d-flex flex-wrap gap-2 px-1 pt-1 mb-0" aria-label="Attachments" data-att-list></ul>
  <p class="att-error px-2 mb-0" id="att-chat-error" hidden></p>
  <div class="d-flex align-items-center gap-1">
    <input class="visually-hidden" type="file" id="att-chat" multiple accept="image/*,.pdf" data-max-size="10485760" data-max-files="4" data-types="image or PDF" aria-describedby="att-chat-error">
    <label class="att-tool d-grid flex-shrink-0 align-items-center justify-content-center" for="att-chat"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 5v14M5 12h14"/></svg><span class="visually-hidden">Add photos or PDFs</span></label>
    <label class="visually-hidden" for="att-chat-msg">Message</label>
    <input class="form-control" id="att-chat-msg" type="text" placeholder="Message #site-visits" autocomplete="off">
    <button class="btn btn-primary rounded-circle d-grid flex-shrink-0 align-items-center justify-content-center" type="button" aria-label="Send message"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
  </div>
  <p class="visually-hidden" aria-live="polite" data-att-live></p>
  <template><li class="att-thumb position-relative overflow-hidden"><img class="d-block w-100 h-100" alt="" data-thumb><span data-ext></span><button class="btn-close" type="button" data-remove></button></li></template>
</div></div>
CSS
/* Inline attachments for Bootstrap 5: borderless .form-control fields inside a .card, .btn-close on every chip,
   .btn for send. The paperclip <label> opens the visually hidden file input; drops land anywhere on the composer. */
.att{--att-accent:#3b5bdb;--att-accent-ink:#2f4ac0;--att-tint:#eef2ff;--att-line:#d5dbe5;--att-muted:#5b6474;--att-danger:#b42318;
  --bs-border-color:#d5dbe5;color:#0f172a}
.att[data-drag]::after{content:"Drop files to attach";position:absolute;inset:0;z-index:5;display:grid;place-items:center;font-weight:700;color:var(--att-accent-ink);
  background:rgba(238,242,255,.94);border:2px dashed var(--att-accent);border-radius:inherit;pointer-events:none}
.att-mail{border-radius:16px;box-shadow:0 12px 32px -16px rgba(15,23,42,.25)}
.att-field{padding:10px 18px;border-bottom:1px solid #eef1f6;font-size:.9rem}
.att-field label{width:58px;color:var(--att-muted)}
.att .form-control{padding:4px 0;font-size:inherit;color:inherit;background:none;border:0;border-radius:4px;box-shadow:none}
.att .form-control:focus-visible{outline:2px solid var(--att-accent);outline-offset:2px}
.att textarea.form-control{min-height:120px;padding:14px 18px;font-size:.92rem;line-height:1.6;border-radius:0}
.att-chips:empty{display:none}
.att-chip{max-width:100%;padding:5px 5px 5px 10px;font-size:.82rem;background:#f8fafc;border:1px solid var(--att-line);border-radius:10px}
.att-chip svg{width:16px;height:16px;color:var(--att-accent)}
.att-name{max-width:190px}
.att-size{color:var(--att-muted)}
.att .btn-close{--bs-btn-close-opacity:.7;--bs-btn-close-focus-shadow:0 0 0 2px var(--att-accent);width:26px;height:26px;padding:0;background-size:9px;border-radius:7px}
.att .btn-close:hover{background-color:#fef3f2}
.att-bar{padding:10px 12px 10px 18px;border-top:1px solid #eef1f6}
.att-total{font-size:.8rem;color:var(--att-muted)}
.att-tool{width:38px;height:38px;color:#344054;border-radius:10px;cursor:pointer;transition:background-color .15s}
.att-tool:hover{background:#f1f5f9;color:#0f172a}
.att-tool svg{width:20px;height:20px}
.visually-hidden:focus-visible + .att-tool{outline:3px solid var(--att-accent);outline-offset:1px}
.att .btn-primary{--bs-btn-bg:var(--att-accent);--bs-btn-border-color:var(--att-accent);--bs-btn-hover-bg:var(--att-accent-ink);--bs-btn-hover-border-color:var(--att-accent-ink);
  --bs-btn-padding-x:16px;--bs-btn-padding-y:9px;--bs-btn-font-size:.88rem;--bs-btn-font-weight:600;--bs-btn-border-radius:10px}
.att .btn-primary:focus-visible{outline:3px solid var(--att-accent);outline-offset:2px;box-shadow:none}
.att .btn-primary svg{width:16px;height:16px}
.att-error{font-size:.82rem;font-weight:600;color:var(--att-danger)}
.att-chat{padding:10px;border-radius:22px;box-shadow:0 12px 32px -16px rgba(15,23,42,.25)}
.att-thumb{width:64px;height:64px;border-radius:12px;background:#e2e8f0}
.att-thumb img{object-fit:cover}
.att-thumb .btn-close{--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e");
  position:absolute;top:4px;right:4px;width:22px;height:22px;background-size:8px;background-color:rgba(15,23,42,.78);
  border-radius:50%;opacity:1;--bs-btn-close-focus-shadow:0 0 0 2px #fff,0 0 0 4px var(--att-accent)}
.att-thumb .btn-close:hover{background-color:var(--att-danger)}
.att-thumb[data-kind="file"]{display:grid;place-items:center;background:var(--att-tint);color:var(--att-accent-ink);font-size:.62rem;font-weight:800;letter-spacing:.04em}
.att-thumb[data-kind="file"] img{display:none!important}
.att-chat .att-tool{border-radius:50%}
.att-chat .form-control{height:40px;padding:0 6px;font-size:.92rem;border-radius:6px}
.att-chat .form-control::placeholder{color:var(--att-muted);opacity:1}
.att-chat .btn-primary{width:40px;height:40px;--bs-btn-padding-x:0;--bs-btn-padding-y:0}
@media (prefers-reduced-motion:reduce){.att-tool,.att .btn{transition:none}}
JavaScript
// Inline attachments: files come from the paperclip input or a drop anywhere on the composer. Each is checked
// (type, size, count, total), shown as a chip or thumbnail, and input.files is kept in step with DataTransfer.
document.querySelectorAll('[data-att]').forEach(box => {
  const input = box.querySelector('input[type="file"]');
  const list = box.querySelector('[data-att-list]');
  const tpl = box.querySelector('template');
  const live = box.querySelector('[data-att-live]');
  const total = box.querySelector('[data-att-total]');
  const error = document.getElementById(input.id + '-error');
  const maxSize = Number(input.dataset.maxSize) || Infinity, maxTotal = Number(input.dataset.maxTotal) || Infinity;
  const maxFiles = Number(input.dataset.maxFiles) || Infinity;
  const accept = input.accept.split(',').map(a => a.trim().toLowerCase()).filter(Boolean);
  const urls = new Map();
  let files = [];
  const fmt = b => b < 1048576 ? Math.max(1, Math.round(b / 1024)) + ' KB' : (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
  const sum = () => files.reduce((n, f) => n + f.size, 0);
  const typeOk = f => !accept.length || accept.some(a => a[0] === '.' ? f.name.toLowerCase().endsWith(a) : a.endsWith('/*') ? f.type.startsWith(a.slice(0, -1)) : f.type === a);

  function render() {
    list.replaceChildren(...files.map(f => {
      const li = tpl.content.firstElementChild.cloneNode(true), img = li.querySelector('[data-thumb]');
      const name = li.querySelector('[data-name]'), size = li.querySelector('[data-size]'), ext = li.querySelector('[data-ext]');
      if (name) name.textContent = f.name;
      if (size) size.textContent = fmt(f.size);
      if (img && f.type.startsWith('image/')) { if (!urls.has(f)) urls.set(f, URL.createObjectURL(f)); img.src = urls.get(f); img.alt = f.name; }
      else { li.dataset.kind = 'file'; if (ext) ext.textContent = (f.name.split('.').pop() || 'file').slice(0, 4).toUpperCase(); }
      const btn = li.querySelector('[data-remove]');
      btn.setAttribute('aria-label', 'Remove ' + f.name);
      btn.addEventListener('click', () => remove(f));
      return li;
    }));
    if (total) total.textContent = files.length ? files.length + (files.length === 1 ? ' file' : ' files') + ' · ' + fmt(sum()) + ' of ' + fmt(maxTotal) : 'Up to ' + fmt(maxTotal) + ' in total';
    const dt = new DataTransfer(); files.forEach(f => dt.items.add(f)); input.files = dt.files;
  }
  function remove(f) {
    const i = files.indexOf(f);
    files.splice(i, 1); URL.revokeObjectURL(urls.get(f)); urls.delete(f); render();
    live.textContent = 'Removed ' + f.name + '.';
    const next = list.querySelectorAll('[data-remove]')[Math.min(i, files.length - 1)];
    (next || input).focus();
  }
  function add(picked) {
    const added = [], problems = [];
    for (const f of picked) {
      if (files.some(g => g.name === f.name && g.size === f.size)) continue;
      if (!typeOk(f)) problems.push(f.name + ' can’t be attached: only ' + input.dataset.types + ' files are allowed.');
      else if (f.size > maxSize) problems.push(f.name + ' is ' + fmt(f.size) + '; each file must be under ' + fmt(maxSize) + '.');
      else if (sum() + f.size > maxTotal) problems.push(f.name + ' would take the total over ' + fmt(maxTotal) + '.');
      else if (files.length >= maxFiles) problems.push(f.name + ' wasn’t added: up to ' + maxFiles + ' files per message.');
      else { files.push(f); added.push(f.name); }
    }
    render();
    error.textContent = problems.join(' ');
    error.hidden = !problems.length;
    live.textContent = (added.length ? 'Attached ' + added.join(', ') + '. ' : '') + problems.join(' ');
  }
  input.addEventListener('change', () => add([...input.files]));
  box.addEventListener('dragover', e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); box.setAttribute('data-drag', ''); } });
  box.addEventListener('dragleave', e => { if (!box.contains(e.relatedTarget)) box.removeAttribute('data-drag'); });
  box.addEventListener('drop', e => { if (!e.dataTransfer.files.length) return; e.preventDefault(); box.removeAttribute('data-drag'); add([...e.dataTransfer.files]); });
});

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-att-muted uppercase">Email composer attachments</span>
<div class="relative grid rounded-2xl border border-att-line bg-white shadow-[0_12px_32px_-16px_rgba(15,23,42,.25)] data-drag:after:absolute data-drag:after:inset-0 data-drag:after:z-[5] data-drag:after:grid data-drag:after:place-items-center data-drag:after:rounded-[inherit] data-drag:after:border-2 data-drag:after:border-dashed data-drag:after:border-att-accent data-drag:after:bg-[#eef2ff]/95 data-drag:after:font-bold data-drag:after:text-att-accent-ink data-drag:after:content-['Drop_files_to_attach']" data-att>
  <div class="flex items-center gap-2.5 border-b border-[#eef1f6] px-[18px] py-2.5 text-[.9rem]"><label class="w-[58px] flex-none text-att-muted" for="att-to">To</label><input class="min-w-0 flex-1 rounded py-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-att-accent" id="att-to" type="email" value="[email protected]" autocomplete="off"></div>
  <div class="flex items-center gap-2.5 border-b border-[#eef1f6] px-[18px] py-2.5 text-[.9rem]"><label class="w-[58px] flex-none text-att-muted" for="att-subject">Subject</label><input class="min-w-0 flex-1 rounded py-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-att-accent" id="att-subject" type="text" value="Crew list and permits for 14 October"></div>
  <textarea class="block min-h-[120px] w-full resize-y px-[18px] py-3.5 text-[.92rem] leading-[1.6] focus-visible:rounded focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-att-accent" aria-label="Message">Hi Lena, attached are the signed crew list and the harbour permit for the October charter. The insurance certificate follows tomorrow.</textarea>
  <ul class="flex flex-wrap gap-2 px-[18px] pb-3 empty:hidden" aria-label="Attachments" data-att-list></ul>
  <p class="px-[18px] pb-2.5 text-[.82rem] font-semibold text-att-danger" id="att-mail-error" hidden></p>
  <div class="flex items-center gap-1.5 border-t border-[#eef1f6] py-2.5 pr-3 pl-[18px]">
    <input class="peer sr-only" type="file" id="att-mail" multiple data-max-total="26214400" aria-describedby="att-mail-total att-mail-error">
    <label class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] text-[#344054] transition-[background-color] peer-focus-visible:outline-3 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-att-accent hover:bg-slate-100 hover:text-slate-900 motion-reduce:transition-none" for="att-mail"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m21.4 11.1-8.9 8.9a5.5 5.5 0 0 1-7.8-7.8l8.9-8.9a3.7 3.7 0 0 1 5.2 5.2l-8.9 8.9a1.8 1.8 0 0 1-2.6-2.6l8.2-8.2"/></svg><span class="sr-only">Attach files</span></label>
    <p class="mr-auto ml-1.5 text-[.8rem] text-att-muted" id="att-mail-total" data-att-total>Up to 25 MB in total</p>
    <button class="inline-flex cursor-pointer items-center gap-2 rounded-[10px] bg-att-accent px-4 py-[9px] text-[.88rem] font-semibold text-white transition-[background-color] hover:bg-att-accent-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-att-accent motion-reduce:transition-none" type="button">Send<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M22 2 11 13"/><path d="M22 2 15 22l-4-9-9-4Z"/></svg></button>
  </div>
  <p class="sr-only" aria-live="polite" data-att-live></p>
  <template><li class="flex max-w-full items-center gap-2 rounded-[10px] border border-att-line bg-slate-50 py-[5px] pr-[5px] pl-2.5 text-[.82rem]"><svg class="size-4 flex-none text-att-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg><span class="max-w-[190px] min-w-0 truncate font-semibold" data-name></span><span class="flex-none text-att-muted" data-size></span><button class="grid size-[26px] flex-none cursor-pointer place-items-center rounded-[7px] text-[#475467] hover:bg-[#fef3f2] hover:text-att-danger focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-att-accent" type="button" data-remove><svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button></li></template>
</div></div>

<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-att-muted uppercase">Chat message bar with previews</span>
<div class="relative grid gap-2 rounded-[22px] border border-att-line bg-white p-2.5 shadow-[0_12px_32px_-16px_rgba(15,23,42,.25)] data-drag:after:absolute data-drag:after:inset-0 data-drag:after:z-[5] data-drag:after:grid data-drag:after:place-items-center data-drag:after:rounded-[inherit] data-drag:after:border-2 data-drag:after:border-dashed data-drag:after:border-att-accent data-drag:after:bg-[#eef2ff]/95 data-drag:after:font-bold data-drag:after:text-att-accent-ink data-drag:after:content-['Drop_files_to_attach']" data-att>
  <ul class="flex flex-wrap gap-2 px-1 pt-1 empty:hidden" aria-label="Attachments" data-att-list></ul>
  <p class="px-2 text-[.82rem] font-semibold text-att-danger" id="att-chat-error" hidden></p>
  <div class="flex items-center gap-1.5">
    <input class="peer sr-only" type="file" id="att-chat" multiple accept="image/*,.pdf" data-max-size="10485760" data-max-files="4" data-types="image or PDF" aria-describedby="att-chat-error">
    <label class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-full text-[#344054] transition-[background-color] peer-focus-visible:outline-3 peer-focus-visible:outline-offset-1 peer-focus-visible:outline-att-accent hover:bg-slate-100 hover:text-slate-900 motion-reduce:transition-none" for="att-chat"><svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 5v14M5 12h14"/></svg><span class="sr-only">Add photos or PDFs</span></label>
    <label class="sr-only" for="att-chat-msg">Message</label>
    <input class="h-10 min-w-0 flex-1 rounded-md px-1.5 text-[.92rem] placeholder:text-att-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-att-accent" id="att-chat-msg" type="text" placeholder="Message #site-visits" autocomplete="off">
    <button class="grid size-10 flex-none cursor-pointer place-items-center rounded-full bg-att-accent text-white transition-[background-color] hover:bg-att-accent-ink focus-visible:outline-3 focus-visible:outline-offset-2 focus-visible:outline-att-accent motion-reduce:transition-none" type="button" aria-label="Send message"><svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M12 19V5M5 12l7-7 7 7"/></svg></button>
  </div>
  <p class="sr-only" aria-live="polite" data-att-live></p>
  <template><li class="group relative size-16 overflow-hidden rounded-xl bg-slate-200 data-[kind=file]:grid data-[kind=file]:place-items-center data-[kind=file]:bg-att-tint data-[kind=file]:text-[.62rem] data-[kind=file]:font-extrabold data-[kind=file]:tracking-[.04em] data-[kind=file]:text-att-accent-ink"><img class="block size-full object-cover group-data-[kind=file]:hidden" alt="" data-thumb><span data-ext></span><button class="absolute top-1 right-1 grid size-[22px] cursor-pointer place-items-center rounded-full bg-slate-900/80 text-white hover:bg-att-danger focus-visible:shadow-[0_0_0_4px_var(--color-att-accent)] focus-visible:outline-2 focus-visible:outline-white" type="button" data-remove><svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button></li></template>
</div></div>
CSS
@theme {
  --color-att-accent: #3b5bdb;
  --color-att-accent-ink: #2f4ac0;
  --color-att-tint: #eef2ff;
  --color-att-line: #d5dbe5;
  --color-att-muted: #5b6474;
  --color-att-danger: #b42318;
}
JavaScript
// Inline attachments: files come from the paperclip input or a drop anywhere on the composer. Each is checked
// (type, size, count, total), shown as a chip or thumbnail, and input.files is kept in step with DataTransfer.
document.querySelectorAll('[data-att]').forEach(box => {
  const input = box.querySelector('input[type="file"]');
  const list = box.querySelector('[data-att-list]');
  const tpl = box.querySelector('template');
  const live = box.querySelector('[data-att-live]');
  const total = box.querySelector('[data-att-total]');
  const error = document.getElementById(input.id + '-error');
  const maxSize = Number(input.dataset.maxSize) || Infinity, maxTotal = Number(input.dataset.maxTotal) || Infinity;
  const maxFiles = Number(input.dataset.maxFiles) || Infinity;
  const accept = input.accept.split(',').map(a => a.trim().toLowerCase()).filter(Boolean);
  const urls = new Map();
  let files = [];
  const fmt = b => b < 1048576 ? Math.max(1, Math.round(b / 1024)) + ' KB' : (b / 1048576).toFixed(1).replace(/\.0$/, '') + ' MB';
  const sum = () => files.reduce((n, f) => n + f.size, 0);
  const typeOk = f => !accept.length || accept.some(a => a[0] === '.' ? f.name.toLowerCase().endsWith(a) : a.endsWith('/*') ? f.type.startsWith(a.slice(0, -1)) : f.type === a);

  function render() {
    list.replaceChildren(...files.map(f => {
      const li = tpl.content.firstElementChild.cloneNode(true), img = li.querySelector('[data-thumb]');
      const name = li.querySelector('[data-name]'), size = li.querySelector('[data-size]'), ext = li.querySelector('[data-ext]');
      if (name) name.textContent = f.name;
      if (size) size.textContent = fmt(f.size);
      if (img && f.type.startsWith('image/')) { if (!urls.has(f)) urls.set(f, URL.createObjectURL(f)); img.src = urls.get(f); img.alt = f.name; }
      else { li.dataset.kind = 'file'; if (ext) ext.textContent = (f.name.split('.').pop() || 'file').slice(0, 4).toUpperCase(); }
      const btn = li.querySelector('[data-remove]');
      btn.setAttribute('aria-label', 'Remove ' + f.name);
      btn.addEventListener('click', () => remove(f));
      return li;
    }));
    if (total) total.textContent = files.length ? files.length + (files.length === 1 ? ' file' : ' files') + ' · ' + fmt(sum()) + ' of ' + fmt(maxTotal) : 'Up to ' + fmt(maxTotal) + ' in total';
    const dt = new DataTransfer(); files.forEach(f => dt.items.add(f)); input.files = dt.files;
  }
  function remove(f) {
    const i = files.indexOf(f);
    files.splice(i, 1); URL.revokeObjectURL(urls.get(f)); urls.delete(f); render();
    live.textContent = 'Removed ' + f.name + '.';
    const next = list.querySelectorAll('[data-remove]')[Math.min(i, files.length - 1)];
    (next || input).focus();
  }
  function add(picked) {
    const added = [], problems = [];
    for (const f of picked) {
      if (files.some(g => g.name === f.name && g.size === f.size)) continue;
      if (!typeOk(f)) problems.push(f.name + ' can’t be attached: only ' + input.dataset.types + ' files are allowed.');
      else if (f.size > maxSize) problems.push(f.name + ' is ' + fmt(f.size) + '; each file must be under ' + fmt(maxSize) + '.');
      else if (sum() + f.size > maxTotal) problems.push(f.name + ' would take the total over ' + fmt(maxTotal) + '.');
      else if (files.length >= maxFiles) problems.push(f.name + ' wasn’t added: up to ' + maxFiles + ' files per message.');
      else { files.push(f); added.push(f.name); }
    }
    render();
    error.textContent = problems.join(' ');
    error.hidden = !problems.length;
    live.textContent = (added.length ? 'Attached ' + added.join(', ') + '. ' : '') + problems.join(' ');
  }
  input.addEventListener('change', () => add([...input.files]));
  box.addEventListener('dragover', e => { if (e.dataTransfer.types.includes('Files')) { e.preventDefault(); box.setAttribute('data-drag', ''); } });
  box.addEventListener('dragleave', e => { if (!box.contains(e.relatedTarget)) box.removeAttribute('data-drag'); });
  box.addEventListener('drop', e => { if (!e.dataTransfer.files.length) return; e.preventDefault(); box.removeAttribute('data-drag'); add([...e.dataTransfer.files]); });
});

About this set

Attachment controls for places where uploading is secondary to writing. Email composer attachments is a message draft with To and Subject fields, a body and a toolbar: the paperclip opens the file picker, attached files sit above the toolbar as chips with a document icon, name, size and remove button, and a running total reads 2 files, 2.2 MB of 25 MB so people see how close they are to the limit before sending. Chat message bar with previews is a rounded input bar with a plus button, a message field and a round send button; images appear above it as 64 pixel thumbnails and PDFs as labelled tiles, each with its own remove button, up to four per message. In both, files can also be dropped anywhere on the composer, which shows a Drop files to attach overlay while a file is dragged over it. A single script handles both: it checks type, size per file, the number of files and the total size, explains any rejection by name, previews images with object URLs and keeps input.files in step with the chips. Use the email pattern for forms and support tickets and the chat bar for messaging.

What’s included

  • Paperclip or plus button backed by a real file input
  • Drop anywhere on the composer with a visible overlay
  • Chips with name, size and remove button
  • Running total against a size limit
  • Image thumbnails and file tiles in the chat bar

Accessibility

The icon-only attach buttons are labels with visually hidden text, so the input is named Attach files or Add photos or PDFs, and the send buttons are named too. Chips have remove buttons named after their file, focus moves to the next chip after a removal, and attached, removed and rejected files are announced in a polite live region. Every field and button has a visible focus style, and transitions switch off under prefers-reduced-motion.

Customise it

Change --att-accent, --att-line and --att-danger on .att, and set data-max-total, data-max-size, data-max-files and accept on the input. Bootstrap uses a .card, borderless .form-control fields and .btn-close; Tailwind reads the att colours from @theme.