Open demo

CSS code

HTML
<div><span class="lbl">Chips with suggestions, up to five</span><div class="card">
<div class="tg" data-tags data-max="5">
  <div class="tg-head"><label class="tg-label" for="tg-skills-native">Skills</label><span class="tg-counter" data-counter aria-hidden="true"></span></div>
  <select class="tg-native" id="tg-skills-native" name="skills" multiple size="6" aria-describedby="tg-skills-hint">
    <option selected>Figma</option><option>Framer</option><option>Webflow</option><option>Adobe Illustrator</option><option>After Effects</option><option>Blender</option>
    <option selected>Prototyping</option><option>User research</option><option selected>Design systems</option><option>Accessibility audits</option><option>Motion design</option><option>Copywriting</option>
  </select>
  <div class="tg-ui" data-ui hidden>
    <div class="tg-field" data-field>
      <ul class="tg-chips" data-chips aria-label="Selected skills"></ul>
      <input class="tg-input" id="tg-skills-input" type="text" placeholder="Add a skill" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="tg-skills-list" aria-describedby="tg-skills-hint">
    </div>
    <div class="tg-pop" data-popup hidden>
      <ul class="tg-list" role="listbox" id="tg-skills-list" aria-label="Skills" aria-multiselectable="true">
        <li class="tg-opt" role="option" id="tg-skills-1" data-value="Figma"><span data-label>Figma</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-skills-2" data-value="Framer"><span data-label>Framer</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-skills-3" data-value="Webflow"><span data-label>Webflow</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-skills-4" data-value="Adobe Illustrator"><span data-label>Adobe Illustrator</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-skills-5" data-value="After Effects"><span data-label>After Effects</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-skills-6" data-value="Blender"><span data-label>Blender</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-skills-7" data-value="Prototyping"><span data-label>Prototyping</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-skills-8" data-value="User research"><span data-label>User research</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-skills-9" data-value="Design systems"><span data-label>Design systems</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-skills-10" data-value="Accessibility audits"><span data-label>Accessibility audits</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-skills-11" data-value="Motion design"><span data-label>Motion design</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-skills-12" data-value="Copywriting"><span data-label>Copywriting</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
      </ul>
      <p class="tg-msg" data-empty hidden>No matching skills.</p>
    </div>
    <template><li class="tg-chip"><span data-text></span><button type="button" class="tg-x"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg></button></li></template>
  </div>
  <p class="tg-hint" id="tg-skills-hint">Pick up to five. Backspace in the empty field removes the last one.</p>
  <p class="sr-only" role="status"></p>
</div>
</div></div>

<div><span class="lbl">Invite by name or email, with avatars</span><div class="card">
<div class="tg" data-tags data-create="email">
  <label class="tg-label" for="tg-team-native">Invite teammates</label>
  <select class="tg-native" id="tg-team-native" name="invite" multiple size="6" aria-describedby="tg-team-hint">
    <option value="[email protected]" data-initials="AO" data-tone="#be185d" selected>Amara Okafor</option>
    <option value="[email protected]" data-initials="BS" data-tone="#0369a1">Bruno Silva</option>
    <option value="[email protected]" data-initials="CW" data-tone="#15803d" selected>Chen Wei</option>
    <option value="[email protected]" data-initials="DH" data-tone="#b45309">Dagny Holm</option>
    <option value="[email protected]" data-initials="EY" data-tone="#7c3aed">Elif Yılmaz</option>
    <option value="[email protected]" data-initials="FH" data-tone="#0f766e">Farah Haddad</option>
  </select>
  <div class="tg-ui" data-ui hidden>
    <div class="tg-field" data-field>
      <ul class="tg-chips" data-chips aria-label="People to invite"></ul>
      <input class="tg-input" id="tg-team-input" type="text" placeholder="Name or email" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="tg-team-list" aria-describedby="tg-team-hint">
    </div>
    <div class="tg-pop" data-popup hidden>
      <ul class="tg-list" role="listbox" id="tg-team-list" aria-label="People" aria-multiselectable="true">
        <li class="tg-opt" role="option" id="tg-team-1" data-value="[email protected]"><span class="tg-av" style="--tone:#be185d" aria-hidden="true">AO</span><span><span data-label>Amara Okafor</span> <span class="tg-sub">[email protected]</span></span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-team-2" data-value="[email protected]"><span class="tg-av" style="--tone:#0369a1" aria-hidden="true">BS</span><span><span data-label>Bruno Silva</span> <span class="tg-sub">[email protected]</span></span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-team-3" data-value="[email protected]"><span class="tg-av" style="--tone:#15803d" aria-hidden="true">CW</span><span><span data-label>Chen Wei</span> <span class="tg-sub">[email protected]</span></span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-team-4" data-value="[email protected]"><span class="tg-av" style="--tone:#b45309" aria-hidden="true">DH</span><span><span data-label>Dagny Holm</span> <span class="tg-sub">[email protected]</span></span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-team-5" data-value="[email protected]"><span class="tg-av" style="--tone:#7c3aed" aria-hidden="true">EY</span><span><span data-label>Elif Yılmaz</span> <span class="tg-sub">[email protected]</span></span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="tg-opt" role="option" id="tg-team-6" data-value="[email protected]"><span class="tg-av" style="--tone:#0f766e" aria-hidden="true">FH</span><span><span data-label>Farah Haddad</span> <span class="tg-sub">[email protected]</span></span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
      </ul>
      <p class="tg-msg" data-empty hidden>No one on the team matches. Type a full email address to invite someone new.</p>
      <p class="tg-msg" data-create hidden>Press Enter to invite <b data-new></b></p>
    </div>
    <template><li class="tg-chip has-av"><span class="tg-av" data-avatar aria-hidden="true"></span><span data-text></span><button type="button" class="tg-x"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg></button></li></template>
  </div>
  <p class="tg-hint" id="tg-team-hint">Type a name, or any email address and press Enter or a comma.</p>
  <p class="sr-only" role="status"></p>
</div>
</div></div>
CSS
/* Tag multiselect: an editable combobox (WAI-ARIA APG, list autocomplete) whose listbox allows several picks.
   Picks become chips with their own remove buttons; a hidden <select multiple> keeps the real form value. */
.tg{--tg-accent:#6d28d9;--tg-tint:#f1ecfe;--tg-chip:#ece6fd;--tg-chip-ink:#4c1d95;--tg-ring:rgba(109,40,217,.25);--tg-line:#8b88a0;
  --tg-ink:#1d1b2c;--tg-muted:#5c596e;position:relative;display:grid;gap:7px;color:var(--tg-ink)}
.tg [hidden]{display:none!important}
.tg .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}
.tg-head{display:flex;align-items:baseline;justify-content:space-between;gap:12px}
.tg-label{font-size:.9rem;font-weight:600}
.tg-counter{font-size:.8rem;color:var(--tg-muted);font-variant-numeric:tabular-nums}
.tg[data-full] .tg-counter{color:var(--tg-accent);font-weight:700}
.tg-hint{font-size:.82rem;color:var(--tg-muted)}
.tg-native{width:100%;padding:6px;font:inherit;color:inherit;background:#fff;border:1px solid var(--tg-line);border-radius:12px}
.tg-ui{position:relative}
/* the field: chips, then the text input */
.tg-field{display:flex;flex-wrap:wrap;align-items:center;gap:6px;min-height:46px;padding:6px 8px;background:#fff;border:1px solid var(--tg-line);
  border-radius:12px;box-shadow:0 1px 2px rgba(29,27,44,.06);cursor:text;transition:border-color .15s,box-shadow .15s}
.tg-field:hover{border-color:#6a6780}
.tg-field:focus-within{border-color:var(--tg-accent);box-shadow:0 0 0 4px var(--tg-ring)}
.tg-chips{display:flex;flex-wrap:wrap;gap:6px;list-style:none}
.tg-chips:empty{display:none}
.tg-chip{display:inline-flex;align-items:center;gap:5px;height:30px;padding:0 4px 0 11px;border-radius:999px;background:var(--tg-chip);
  color:var(--tg-chip-ink);font-size:.86rem;font-weight:600;white-space:nowrap}
.tg-chip.has-av{padding-left:3px}
.tg-av{display:grid;place-items:center;flex:none;width:24px;height:24px;border-radius:50%;background:var(--tone,#6d28d9);color:#fff;font-size:.66rem;font-weight:700;letter-spacing:.02em}
.tg-x{display:grid;place-items:center;width:22px;height:22px;padding:0;color:inherit;background:none;border:0;border-radius:50%;cursor:pointer}
.tg-x:hover{background:rgba(76,29,149,.14)}
.tg-x:focus-visible{outline:2px solid var(--tg-accent);outline-offset:1px}
.tg-x svg{width:12px;height:12px}
.tg-input{flex:1 0 100px;min-width:0;height:32px;padding:0 6px;font:inherit;font-size:.95rem;color:inherit;background:none;border:0;outline:none}
.tg-input::placeholder{color:var(--tg-muted);opacity:1}
/* the suggestions */
.tg-pop{position:absolute;z-index:30;left:0;right:0;top:calc(100% + 6px);padding:6px;background:#fff;border:1px solid #dcd8e6;border-radius:12px;
  box-shadow:0 18px 40px -12px rgba(29,27,44,.3)}
.tg-list{max-height:250px;overflow-y:auto;overscroll-behavior:contain;list-style:none}
.tg-opt{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;font-size:.93rem;cursor:pointer}
.tg-opt[data-active]{background:var(--tg-tint)}
.tg-opt[data-chosen]{font-weight:600}
.tg-opt[aria-disabled="true"]{color:#6f6c80;cursor:not-allowed}
.tg-opt mark{background:none;color:var(--tg-accent);font-weight:700}
.tg-opt .tg-av{width:30px;height:30px;font-size:.72rem}
.tg-sub{display:block;font-size:.8rem;font-weight:400;color:var(--tg-muted)}
.tg-check{flex:none;width:16px;height:16px;margin-left:auto;color:var(--tg-accent);visibility:hidden}
.tg-opt[data-chosen] .tg-check{visibility:visible}
.tg-msg{padding:8px 10px;font-size:.88rem;color:var(--tg-muted)}
.tg-msg b{color:var(--tg-ink)}
@media (prefers-reduced-motion:reduce){.tg-field{transition:none}}
JavaScript
// Tag multiselect: an editable combobox (WAI-ARIA APG, list autocomplete) with a multi-select listbox.
// Typing filters the suggestions. Down / Up move through them (aria-activedescendant), Enter adds or removes the
// highlighted one, Escape closes the list (or clears the text when it is already closed), Backspace in an empty
// field removes the last chip, and every chip has its own remove button. A polite status line reads out changes.
// The <select multiple> holds the real form value and the starting picks; without JavaScript it stays visible.
// data-max="5" caps the number of picks; data-create="email" lets people add any email address.
(() => {
  document.querySelectorAll('[data-tags]').forEach(root => {
    const native = root.querySelector('select');
    const label = root.querySelector('label');
    const input = root.querySelector('[role="combobox"]');
    const popup = root.querySelector('[data-popup]');
    const list = root.querySelector('[role="listbox"]');
    const chips = root.querySelector('[data-chips]');
    const chip = root.querySelector('template').content.firstElementChild;
    const empty = root.querySelector('[data-empty]');
    const create = root.dataset.create && root.querySelector('[data-create]');
    const counter = root.querySelector('[data-counter]');
    const status = root.querySelector('[role="status"]');
    const max = +root.dataset.max || Infinity;
    const opts = [...list.querySelectorAll('[role="option"]')];
    const names = new Map(opts.map(o => [o, o.querySelector('[data-label]').textContent]));
    const source = v => [...native.options].find(n => n.value.toLowerCase() === v.toLowerCase());
    const isOpen = () => input.getAttribute('aria-expanded') === 'true';
    const isEmail = s => /^[^\s@,]+@[^\s@,]+\.[a-z]{2,}$/i.test(s);
    const canCreate = q => !!create && isEmail(q) && !source(q);
    const say = msg => { status.textContent = msg; };
    let active = null;

    native.hidden = true;
    root.querySelector('[data-ui]').hidden = false;
    label.htmlFor = input.id;

    function render() {
      const on = [...native.selectedOptions];
      chips.replaceChildren(...on.map(n => {
        const li = chip.cloneNode(true);
        li.querySelector('[data-text]').textContent = n.text;
        const av = li.querySelector('[data-avatar]');
        if (av) {
          av.textContent = n.dataset.initials || n.text.charAt(0).toUpperCase();
          if (n.dataset.tone) av.style.setProperty('--tone', n.dataset.tone);
        }
        const x = li.querySelector('button');
        x.dataset.value = n.value;
        x.setAttribute('aria-label', 'Remove ' + n.text);
        return li;
      }));
      const full = on.length >= max;
      opts.forEach(o => {
        const picked = source(o.dataset.value).selected;
        o.setAttribute('aria-selected', picked);
        o.toggleAttribute('data-chosen', picked);
        if (full && !picked) o.setAttribute('aria-disabled', 'true');
        else o.removeAttribute('aria-disabled');
      });
      if (counter) counter.textContent = on.length + ' / ' + max;
      root.toggleAttribute('data-full', full);
    }
    function filter() {
      const q = input.value.trim(), lq = q.toLowerCase();
      const hits = opts.filter(o => {
        const hit = o.textContent.toLowerCase().includes(lq);
        const el = o.querySelector('[data-label]'), name = names.get(o), at = lq ? name.toLowerCase().indexOf(lq) : -1;
        o.hidden = !hit;
        if (at < 0) el.textContent = name;
        else {
          const mark = document.createElement('mark');
          mark.textContent = name.slice(at, at + q.length);
          el.replaceChildren(name.slice(0, at), mark, name.slice(at + q.length));
        }
        return hit;
      });
      if (create) { create.hidden = !canCreate(q); create.querySelector('[data-new]').textContent = q; }
      if (empty) empty.hidden = hits.length > 0 || canCreate(q);
      return hits;
    }
    function setActive(o) {
      if (active) active.removeAttribute('data-active');
      active = o;
      if (!o) return input.removeAttribute('aria-activedescendant');
      o.setAttribute('data-active', '');
      input.setAttribute('aria-activedescendant', o.id);
      const r = o.getBoundingClientRect(), b = list.getBoundingClientRect(); // keep it in view without scrolling the page
      if (r.top < b.top) list.scrollTop -= b.top - r.top;
      else if (r.bottom > b.bottom) list.scrollTop += r.bottom - b.bottom;
    }
    function refresh(announce) {
      const hits = filter(), q = input.value.trim();
      setActive(null);
      popup.hidden = false;
      input.setAttribute('aria-expanded', 'true');
      if (announce) say(!q ? '' : hits.length ? hits.length + (hits.length === 1 ? ' suggestion' : ' suggestions')
        : canCreate(q) ? 'Press Enter to add ' + q : 'No matches');
    }
    function close() {
      popup.hidden = true;
      input.setAttribute('aria-expanded', 'false');
      setActive(null);
    }
    function changed(n, picked) {
      if (!picked && n.hasAttribute('data-new')) n.remove();
      render();
      native.dispatchEvent(new Event('change', { bubbles: true }));
      say(n.text + (picked ? ' added, ' : ' removed, ') + native.selectedOptions.length + ' selected');
    }
    function toggle(o) {
      const n = source(o.dataset.value);
      if (!n.selected && native.selectedOptions.length >= max) return say('You can pick up to ' + max);
      n.selected = !n.selected;
      changed(n, n.selected);
    }
    function add(q) {
      if (native.selectedOptions.length >= max) return say('You can pick up to ' + max);
      const n = new Option(q, q, false, true); // not a default, so a form reset drops it
      n.setAttribute('data-new', '');
      native.add(n);
      input.value = '';
      refresh(false);
      changed(n, true);
    }
    function clearText(keep) {
      if (!input.value) return;
      input.value = '';
      refresh(false);
      if (keep) setActive(keep);
    }

    input.addEventListener('keydown', e => {
      const q = input.value.trim();
      switch (e.key) {
        case 'ArrowDown': case 'ArrowUp': {
          e.preventDefault();
          if (!isOpen()) refresh(false);
          if (e.altKey) break;
          const vis = opts.filter(o => !o.hidden), i = vis.indexOf(active), down = e.key === 'ArrowDown';
          if (vis.length) setActive(vis[i < 0 ? (down ? 0 : vis.length - 1) : (i + (down ? 1 : -1) + vis.length) % vis.length]);
          break;
        }
        case 'Enter':
          if (isOpen() && active) { e.preventDefault(); const o = active; toggle(o); clearText(o); }
          else if (canCreate(q)) { e.preventDefault(); add(q); }
          else if (q) {
            e.preventDefault();
            const o = opts.find(o => names.get(o).toLowerCase() === q.toLowerCase());
            if (o) { toggle(o); clearText(); }
          }
          break;
        case ',':
          if (canCreate(q)) { e.preventDefault(); add(q); }
          break;
        case 'Escape':
          if (isOpen()) { e.preventDefault(); close(); }
          else if (input.value) { e.preventDefault(); input.value = ''; filter(); }
          break;
        case 'Backspace':
          if (!input.value && native.selectedOptions.length) {
            const n = native.selectedOptions[native.selectedOptions.length - 1];
            n.selected = false;
            changed(n, false);
          }
          break;
        case 'Tab': close(); break;
        case 'ArrowLeft': case 'ArrowRight': case 'Home': case 'End': setActive(null); break;
      }
    });
    input.addEventListener('input', () => refresh(true));
    input.addEventListener('click', () => { if (!isOpen()) refresh(false); });
    input.addEventListener('blur', close);
    root.querySelector('[data-field]').addEventListener('click', e => {
      if (e.target === input || e.target.closest('button')) return;
      input.focus();
      if (!isOpen()) refresh(false);
    });
    popup.addEventListener('mousedown', e => e.preventDefault()); // keep focus in the input
    list.addEventListener('click', e => {
      const o = e.target.closest('[role="option"]');
      if (!o) return;
      toggle(o);
      clearText(o);
      setActive(o);
      input.focus();
    });
    list.addEventListener('pointermove', e => {
      const o = e.target.closest('[role="option"]');
      if (o && o !== active) setActive(o);
    });
    chips.addEventListener('click', e => {
      const x = e.target.closest('button');
      if (!x) return;
      const n = source(x.dataset.value);
      n.selected = false;
      changed(n, false);
      input.focus();
    });
    document.addEventListener('pointerdown', e => { if (isOpen() && !root.contains(e.target)) close(); });
    if (native.form) native.form.addEventListener('reset', () => setTimeout(render));
    render();
  });
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Chips with suggestions, up to five</span><div class="card demo-card"><div class="card-body">
<div class="tg" data-tags data-max="5">
  <div class="d-flex align-items-baseline justify-content-between gap-3"><label class="form-label" for="tg-skills-native">Skills</label><span class="tg-counter" data-counter aria-hidden="true"></span></div>
  <select class="form-select tg-native" id="tg-skills-native" name="skills" multiple size="6" aria-describedby="tg-skills-hint">
    <option selected>Figma</option><option>Framer</option><option>Webflow</option><option>Adobe Illustrator</option><option>After Effects</option><option>Blender</option>
    <option selected>Prototyping</option><option>User research</option><option selected>Design systems</option><option>Accessibility audits</option><option>Motion design</option><option>Copywriting</option>
  </select>
  <div class="position-relative" data-ui hidden>
    <div class="form-control tg-field d-flex flex-wrap align-items-center" data-field>
      <ul class="tg-chips d-flex flex-wrap list-unstyled m-0" data-chips aria-label="Selected skills"></ul>
      <input class="tg-input" id="tg-skills-input" type="text" placeholder="Add a skill" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="tg-skills-list" aria-describedby="tg-skills-hint">
    </div>
    <div class="dropdown-menu tg-pop" data-popup hidden>
      <ul class="tg-list list-unstyled m-0" role="listbox" id="tg-skills-list" aria-label="Skills" aria-multiselectable="true">
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-skills-1" data-value="Figma"><span data-label>Figma</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-skills-2" data-value="Framer"><span data-label>Framer</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-skills-3" data-value="Webflow"><span data-label>Webflow</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-skills-4" data-value="Adobe Illustrator"><span data-label>Adobe Illustrator</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-skills-5" data-value="After Effects"><span data-label>After Effects</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-skills-6" data-value="Blender"><span data-label>Blender</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-skills-7" data-value="Prototyping"><span data-label>Prototyping</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-skills-8" data-value="User research"><span data-label>User research</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-skills-9" data-value="Design systems"><span data-label>Design systems</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-skills-10" data-value="Accessibility audits"><span data-label>Accessibility audits</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-skills-11" data-value="Motion design"><span data-label>Motion design</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-skills-12" data-value="Copywriting"><span data-label>Copywriting</span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
      </ul>
      <p class="tg-msg" data-empty hidden>No matching skills.</p>
    </div>
    <template><li class="badge rounded-pill tg-chip d-inline-flex align-items-center"><span data-text></span><button type="button" class="btn-close tg-x"></button></li></template>
  </div>
  <p class="form-text" id="tg-skills-hint">Pick up to five. Backspace in the empty field removes the last one.</p>
  <p class="visually-hidden" role="status"></p>
</div>
</div></div></div>

<div><span class="lbl">Invite by name or email, with avatars</span><div class="card demo-card"><div class="card-body">
<div class="tg" data-tags data-create="email">
  <label class="form-label" for="tg-team-native">Invite teammates</label>
  <select class="form-select tg-native" id="tg-team-native" name="invite" multiple size="6" aria-describedby="tg-team-hint">
    <option value="[email protected]" data-initials="AO" data-tone="#be185d" selected>Amara Okafor</option>
    <option value="[email protected]" data-initials="BS" data-tone="#0369a1">Bruno Silva</option>
    <option value="[email protected]" data-initials="CW" data-tone="#15803d" selected>Chen Wei</option>
    <option value="[email protected]" data-initials="DH" data-tone="#b45309">Dagny Holm</option>
    <option value="[email protected]" data-initials="EY" data-tone="#7c3aed">Elif Yılmaz</option>
    <option value="[email protected]" data-initials="FH" data-tone="#0f766e">Farah Haddad</option>
  </select>
  <div class="position-relative" data-ui hidden>
    <div class="form-control tg-field d-flex flex-wrap align-items-center" data-field>
      <ul class="tg-chips d-flex flex-wrap list-unstyled m-0" data-chips aria-label="People to invite"></ul>
      <input class="tg-input" id="tg-team-input" type="text" placeholder="Name or email" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="tg-team-list" aria-describedby="tg-team-hint">
    </div>
    <div class="dropdown-menu tg-pop" data-popup hidden>
      <ul class="tg-list list-unstyled m-0" role="listbox" id="tg-team-list" aria-label="People" aria-multiselectable="true">
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-team-1" data-value="[email protected]"><span class="tg-av" style="--tone:#be185d" aria-hidden="true">AO</span><span><span data-label>Amara Okafor</span> <span class="tg-sub">[email protected]</span></span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-team-2" data-value="[email protected]"><span class="tg-av" style="--tone:#0369a1" aria-hidden="true">BS</span><span><span data-label>Bruno Silva</span> <span class="tg-sub">[email protected]</span></span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-team-3" data-value="[email protected]"><span class="tg-av" style="--tone:#15803d" aria-hidden="true">CW</span><span><span data-label>Chen Wei</span> <span class="tg-sub">[email protected]</span></span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-team-4" data-value="[email protected]"><span class="tg-av" style="--tone:#b45309" aria-hidden="true">DH</span><span><span data-label>Dagny Holm</span> <span class="tg-sub">[email protected]</span></span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-team-5" data-value="[email protected]"><span class="tg-av" style="--tone:#7c3aed" aria-hidden="true">EY</span><span><span data-label>Elif Yılmaz</span> <span class="tg-sub">[email protected]</span></span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="dropdown-item tg-opt d-flex align-items-center" role="option" id="tg-team-6" data-value="[email protected]"><span class="tg-av" style="--tone:#0f766e" aria-hidden="true">FH</span><span><span data-label>Farah Haddad</span> <span class="tg-sub">[email protected]</span></span><svg class="tg-check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
      </ul>
      <p class="tg-msg" data-empty hidden>No one on the team matches. Type a full email address to invite someone new.</p>
      <p class="tg-msg" data-create hidden>Press Enter to invite <b  data-new></b></p>
    </div>
    <template><li class="badge rounded-pill tg-chip has-av d-inline-flex align-items-center"><span class="tg-av" data-avatar aria-hidden="true"></span><span data-text></span><button type="button" class="btn-close tg-x"></button></li></template>
  </div>
  <p class="form-text" id="tg-team-hint">Type a name, or any email address and press Enter or a comma.</p>
  <p class="visually-hidden" role="status"></p>
</div>
</div></div></div>
CSS
/* Tag multiselect on Bootstrap parts: a .form-control field holding .badge chips with .btn-close buttons and the
   text input (an editable combobox, WAI-ARIA APG list autocomplete), a .dropdown-menu of .dropdown-item suggestions.
   The script sets aria-expanded, data-active and data-chosen; a hidden <select multiple> keeps the real form value. */
.tg{--tg-accent:#6d28d9;--tg-tint:#f1ecfe;--tg-chip:#ece6fd;--tg-chip-ink:#4c1d95;--tg-ring:rgba(109,40,217,.25);--tg-line:#8b88a0;
  --tg-ink:#1d1b2c;--tg-muted:#5c596e;position:relative;display:grid;gap:7px;color:var(--tg-ink)}
.tg [hidden]{display:none!important} /* beats .d-flex on filtered options */
.tg .form-label{margin:0;font-size:.9rem;font-weight:600}
.tg .form-text{margin:0;font-size:.82rem;color:var(--tg-muted)}
.tg-counter{font-size:.8rem;color:var(--tg-muted);font-variant-numeric:tabular-nums}
.tg[data-full] .tg-counter{color:var(--tg-accent);font-weight:700}
.tg-native{--bs-form-select-bg-img:none;border-color:var(--tg-line);border-radius:12px;padding:6px}
.tg .tg-field{gap:6px;min-height:46px;padding:6px 8px;border-color:var(--tg-line);border-radius:12px;box-shadow:0 1px 2px rgba(29,27,44,.06);cursor:text}
.tg .tg-field:hover{border-color:#6a6780}
.tg .tg-field:focus-within{border-color:var(--tg-accent);box-shadow:0 0 0 4px var(--tg-ring)}
.tg-chips{gap:6px}
.tg-chips:empty{display:none!important}
.tg .tg-chip{gap:5px;height:30px;padding:0 4px 0 11px;background:var(--tg-chip);color:var(--tg-chip-ink);font-size:.86rem;font-weight:600;line-height:1}
.tg .tg-chip.has-av{padding-left:3px}
.tg-av{display:grid;place-items:center;flex:none;width:24px;height:24px;border-radius:50%;background:var(--tone,#6d28d9);color:#fff;font-size:.66rem;font-weight:700;letter-spacing:.02em}
.tg .tg-x{--bs-btn-close-bg:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%234c1d95' stroke-width='2.2' stroke-linecap='round'%3E%3Cpath d='m4.5 4.5 7 7m0-7-7 7'/%3E%3C/svg%3E");
  --bs-btn-close-opacity:1;--bs-btn-close-hover-opacity:1;--bs-btn-close-focus-opacity:1;--bs-btn-close-focus-shadow:none;
  width:22px;height:22px;padding:0;background-size:12px;border-radius:50%}
.tg .tg-x:hover{background-color:rgba(76,29,149,.14)}
.tg .tg-x:focus-visible{outline:2px solid var(--tg-accent);outline-offset:1px}
.tg-input{flex:1 0 100px;min-width:0;height:32px;padding:0 6px;font:inherit;font-size:.95rem;color:inherit;background:none;border:0;outline:none}
.tg-input::placeholder{color:var(--tg-muted);opacity:1}
.tg .tg-pop{--bs-dropdown-padding-x:6px;--bs-dropdown-padding-y:6px;--bs-dropdown-border-color:#dcd8e6;--bs-dropdown-border-radius:12px;
  --bs-dropdown-link-color:var(--tg-ink);--bs-dropdown-link-hover-color:var(--tg-ink);--bs-dropdown-link-hover-bg:transparent;
  --bs-dropdown-link-active-color:var(--tg-ink);--bs-dropdown-link-active-bg:transparent;
  display:block;left:0;right:0;top:calc(100% + 6px);z-index:30;box-shadow:0 18px 40px -12px rgba(29,27,44,.3)}
.tg-list{max-height:250px;overflow-y:auto;overscroll-behavior:contain}
.tg .tg-opt{gap:10px;padding:8px 10px;border-radius:8px;font-size:.93rem;white-space:normal;cursor:pointer}
.tg .tg-opt[data-active]{background:var(--tg-tint)}
.tg .tg-opt[data-chosen]{font-weight:600}
.tg .tg-opt[aria-disabled="true"]{color:#6f6c80;cursor:not-allowed}
.tg-opt mark{padding:0;background:none;color:var(--tg-accent);font-weight:700}
.tg-opt .tg-av{width:30px;height:30px;font-size:.72rem}
.tg-sub{display:block;font-size:.8rem;font-weight:400;color:var(--tg-muted)}
.tg-check{flex:none;width:16px;height:16px;margin-left:auto;color:var(--tg-accent);visibility:hidden}
.tg-opt[data-chosen] .tg-check{visibility:visible}
.tg-msg{padding:8px 10px;margin:0;font-size:.88rem;color:var(--tg-muted)}
.tg-msg b{color:var(--tg-ink)}
@media (prefers-reduced-motion:reduce){.tg .tg-field{transition:none}}
JavaScript
// Tag multiselect: an editable combobox (WAI-ARIA APG, list autocomplete) with a multi-select listbox.
// Typing filters the suggestions. Down / Up move through them (aria-activedescendant), Enter adds or removes the
// highlighted one, Escape closes the list (or clears the text when it is already closed), Backspace in an empty
// field removes the last chip, and every chip has its own remove button. A polite status line reads out changes.
// The <select multiple> holds the real form value and the starting picks; without JavaScript it stays visible.
// data-max="5" caps the number of picks; data-create="email" lets people add any email address.
(() => {
  document.querySelectorAll('[data-tags]').forEach(root => {
    const native = root.querySelector('select');
    const label = root.querySelector('label');
    const input = root.querySelector('[role="combobox"]');
    const popup = root.querySelector('[data-popup]');
    const list = root.querySelector('[role="listbox"]');
    const chips = root.querySelector('[data-chips]');
    const chip = root.querySelector('template').content.firstElementChild;
    const empty = root.querySelector('[data-empty]');
    const create = root.dataset.create && root.querySelector('[data-create]');
    const counter = root.querySelector('[data-counter]');
    const status = root.querySelector('[role="status"]');
    const max = +root.dataset.max || Infinity;
    const opts = [...list.querySelectorAll('[role="option"]')];
    const names = new Map(opts.map(o => [o, o.querySelector('[data-label]').textContent]));
    const source = v => [...native.options].find(n => n.value.toLowerCase() === v.toLowerCase());
    const isOpen = () => input.getAttribute('aria-expanded') === 'true';
    const isEmail = s => /^[^\s@,]+@[^\s@,]+\.[a-z]{2,}$/i.test(s);
    const canCreate = q => !!create && isEmail(q) && !source(q);
    const say = msg => { status.textContent = msg; };
    let active = null;

    native.hidden = true;
    root.querySelector('[data-ui]').hidden = false;
    label.htmlFor = input.id;

    function render() {
      const on = [...native.selectedOptions];
      chips.replaceChildren(...on.map(n => {
        const li = chip.cloneNode(true);
        li.querySelector('[data-text]').textContent = n.text;
        const av = li.querySelector('[data-avatar]');
        if (av) {
          av.textContent = n.dataset.initials || n.text.charAt(0).toUpperCase();
          if (n.dataset.tone) av.style.setProperty('--tone', n.dataset.tone);
        }
        const x = li.querySelector('button');
        x.dataset.value = n.value;
        x.setAttribute('aria-label', 'Remove ' + n.text);
        return li;
      }));
      const full = on.length >= max;
      opts.forEach(o => {
        const picked = source(o.dataset.value).selected;
        o.setAttribute('aria-selected', picked);
        o.toggleAttribute('data-chosen', picked);
        if (full && !picked) o.setAttribute('aria-disabled', 'true');
        else o.removeAttribute('aria-disabled');
      });
      if (counter) counter.textContent = on.length + ' / ' + max;
      root.toggleAttribute('data-full', full);
    }
    function filter() {
      const q = input.value.trim(), lq = q.toLowerCase();
      const hits = opts.filter(o => {
        const hit = o.textContent.toLowerCase().includes(lq);
        const el = o.querySelector('[data-label]'), name = names.get(o), at = lq ? name.toLowerCase().indexOf(lq) : -1;
        o.hidden = !hit;
        if (at < 0) el.textContent = name;
        else {
          const mark = document.createElement('mark');
          mark.textContent = name.slice(at, at + q.length);
          el.replaceChildren(name.slice(0, at), mark, name.slice(at + q.length));
        }
        return hit;
      });
      if (create) { create.hidden = !canCreate(q); create.querySelector('[data-new]').textContent = q; }
      if (empty) empty.hidden = hits.length > 0 || canCreate(q);
      return hits;
    }
    function setActive(o) {
      if (active) active.removeAttribute('data-active');
      active = o;
      if (!o) return input.removeAttribute('aria-activedescendant');
      o.setAttribute('data-active', '');
      input.setAttribute('aria-activedescendant', o.id);
      const r = o.getBoundingClientRect(), b = list.getBoundingClientRect(); // keep it in view without scrolling the page
      if (r.top < b.top) list.scrollTop -= b.top - r.top;
      else if (r.bottom > b.bottom) list.scrollTop += r.bottom - b.bottom;
    }
    function refresh(announce) {
      const hits = filter(), q = input.value.trim();
      setActive(null);
      popup.hidden = false;
      input.setAttribute('aria-expanded', 'true');
      if (announce) say(!q ? '' : hits.length ? hits.length + (hits.length === 1 ? ' suggestion' : ' suggestions')
        : canCreate(q) ? 'Press Enter to add ' + q : 'No matches');
    }
    function close() {
      popup.hidden = true;
      input.setAttribute('aria-expanded', 'false');
      setActive(null);
    }
    function changed(n, picked) {
      if (!picked && n.hasAttribute('data-new')) n.remove();
      render();
      native.dispatchEvent(new Event('change', { bubbles: true }));
      say(n.text + (picked ? ' added, ' : ' removed, ') + native.selectedOptions.length + ' selected');
    }
    function toggle(o) {
      const n = source(o.dataset.value);
      if (!n.selected && native.selectedOptions.length >= max) return say('You can pick up to ' + max);
      n.selected = !n.selected;
      changed(n, n.selected);
    }
    function add(q) {
      if (native.selectedOptions.length >= max) return say('You can pick up to ' + max);
      const n = new Option(q, q, false, true); // not a default, so a form reset drops it
      n.setAttribute('data-new', '');
      native.add(n);
      input.value = '';
      refresh(false);
      changed(n, true);
    }
    function clearText(keep) {
      if (!input.value) return;
      input.value = '';
      refresh(false);
      if (keep) setActive(keep);
    }

    input.addEventListener('keydown', e => {
      const q = input.value.trim();
      switch (e.key) {
        case 'ArrowDown': case 'ArrowUp': {
          e.preventDefault();
          if (!isOpen()) refresh(false);
          if (e.altKey) break;
          const vis = opts.filter(o => !o.hidden), i = vis.indexOf(active), down = e.key === 'ArrowDown';
          if (vis.length) setActive(vis[i < 0 ? (down ? 0 : vis.length - 1) : (i + (down ? 1 : -1) + vis.length) % vis.length]);
          break;
        }
        case 'Enter':
          if (isOpen() && active) { e.preventDefault(); const o = active; toggle(o); clearText(o); }
          else if (canCreate(q)) { e.preventDefault(); add(q); }
          else if (q) {
            e.preventDefault();
            const o = opts.find(o => names.get(o).toLowerCase() === q.toLowerCase());
            if (o) { toggle(o); clearText(); }
          }
          break;
        case ',':
          if (canCreate(q)) { e.preventDefault(); add(q); }
          break;
        case 'Escape':
          if (isOpen()) { e.preventDefault(); close(); }
          else if (input.value) { e.preventDefault(); input.value = ''; filter(); }
          break;
        case 'Backspace':
          if (!input.value && native.selectedOptions.length) {
            const n = native.selectedOptions[native.selectedOptions.length - 1];
            n.selected = false;
            changed(n, false);
          }
          break;
        case 'Tab': close(); break;
        case 'ArrowLeft': case 'ArrowRight': case 'Home': case 'End': setActive(null); break;
      }
    });
    input.addEventListener('input', () => refresh(true));
    input.addEventListener('click', () => { if (!isOpen()) refresh(false); });
    input.addEventListener('blur', close);
    root.querySelector('[data-field]').addEventListener('click', e => {
      if (e.target === input || e.target.closest('button')) return;
      input.focus();
      if (!isOpen()) refresh(false);
    });
    popup.addEventListener('mousedown', e => e.preventDefault()); // keep focus in the input
    list.addEventListener('click', e => {
      const o = e.target.closest('[role="option"]');
      if (!o) return;
      toggle(o);
      clearText(o);
      setActive(o);
      input.focus();
    });
    list.addEventListener('pointermove', e => {
      const o = e.target.closest('[role="option"]');
      if (o && o !== active) setActive(o);
    });
    chips.addEventListener('click', e => {
      const x = e.target.closest('button');
      if (!x) return;
      const n = source(x.dataset.value);
      n.selected = false;
      changed(n, false);
      input.focus();
    });
    document.addEventListener('pointerdown', e => { if (isOpen() && !root.contains(e.target)) close(); });
    if (native.form) native.form.addEventListener('reset', () => setTimeout(render));
    render();
  });
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-[#5c596e] uppercase">Chips with suggestions, up to five</span><div class="rounded-2xl border border-[#e6e2ef] bg-white px-6 pt-[22px] pb-6 shadow-[0_1px_2px_rgba(29,27,44,.04)]">
<div class="group/tg relative grid gap-[7px] text-tg-ink" data-tags data-max="5">
  <div class="flex items-baseline justify-between gap-3"><label class="text-[.9rem] font-semibold" for="tg-skills-native">Skills</label><span class="text-[.8rem] text-tg-muted tabular-nums group-data-full/tg:font-bold group-data-full/tg:text-tg-accent" data-counter aria-hidden="true"></span></div>
  <select class="w-full rounded-xl border border-tg-line bg-white p-1.5" id="tg-skills-native" name="skills" multiple size="6" aria-describedby="tg-skills-hint">
    <option selected>Figma</option><option>Framer</option><option>Webflow</option><option>Adobe Illustrator</option><option>After Effects</option><option>Blender</option>
    <option selected>Prototyping</option><option>User research</option><option selected>Design systems</option><option>Accessibility audits</option><option>Motion design</option><option>Copywriting</option>
  </select>
  <div class="relative" data-ui hidden>
    <div class="flex min-h-[46px] cursor-text flex-wrap items-center gap-1.5 rounded-xl border border-tg-line bg-white px-2 py-1.5 shadow-[0_1px_2px_rgba(29,27,44,.06)] transition-[border-color,box-shadow] duration-150 hover:border-[#6a6780] focus-within:border-tg-accent focus-within:shadow-[0_0_0_4px_rgba(109,40,217,.25)] motion-reduce:transition-none" data-field>
      <ul class="flex flex-wrap gap-1.5 empty:hidden" data-chips aria-label="Selected skills"></ul>
      <input class="h-8 min-w-0 flex-[1_0_100px] bg-transparent px-1.5 text-[.95rem] outline-none placeholder:text-tg-muted" id="tg-skills-input" type="text" placeholder="Add a skill" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="tg-skills-list" aria-describedby="tg-skills-hint">
    </div>
    <div class="absolute top-[calc(100%+6px)] right-0 left-0 z-30 rounded-xl border border-[#dcd8e6] bg-white p-1.5 shadow-[0_18px_40px_-12px_rgba(29,27,44,.3)]" data-popup hidden>
      <ul class="max-h-[250px] overflow-y-auto overscroll-contain" role="listbox" id="tg-skills-list" aria-label="Skills" aria-multiselectable="true">
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-skills-1" data-value="Figma"><span data-label>Figma</span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-skills-2" data-value="Framer"><span data-label>Framer</span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-skills-3" data-value="Webflow"><span data-label>Webflow</span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-skills-4" data-value="Adobe Illustrator"><span data-label>Adobe Illustrator</span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-skills-5" data-value="After Effects"><span data-label>After Effects</span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-skills-6" data-value="Blender"><span data-label>Blender</span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-skills-7" data-value="Prototyping"><span data-label>Prototyping</span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-skills-8" data-value="User research"><span data-label>User research</span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-skills-9" data-value="Design systems"><span data-label>Design systems</span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-skills-10" data-value="Accessibility audits"><span data-label>Accessibility audits</span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-skills-11" data-value="Motion design"><span data-label>Motion design</span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-skills-12" data-value="Copywriting"><span data-label>Copywriting</span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
      </ul>
      <p class="px-2.5 py-2 text-[.88rem] text-tg-muted" data-empty hidden>No matching skills.</p>
    </div>
    <template><li class="inline-flex h-[30px] items-center gap-[5px] rounded-full bg-tg-chip pr-1 pl-[11px] text-[.86rem] font-semibold whitespace-nowrap text-tg-chip-ink"><span data-text></span><button type="button" class="grid size-[22px] cursor-pointer place-items-center rounded-full hover:bg-[rgba(76,29,149,.14)] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-tg-accent [&_svg]:size-3"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg></button></li></template>
  </div>
  <p class="text-[.82rem] text-tg-muted" id="tg-skills-hint">Pick up to five. Backspace in the empty field removes the last one.</p>
  <p class="sr-only" role="status"></p>
</div>
</div></div>

<div><span class="mb-3 block text-[.72rem] tracking-[.12em] text-[#5c596e] uppercase">Invite by name or email, with avatars</span><div class="rounded-2xl border border-[#e6e2ef] bg-white px-6 pt-[22px] pb-6 shadow-[0_1px_2px_rgba(29,27,44,.04)]">
<div class="group/tg relative grid gap-[7px] text-tg-ink" data-tags data-create="email">
  <label class="text-[.9rem] font-semibold" for="tg-team-native">Invite teammates</label>
  <select class="w-full rounded-xl border border-tg-line bg-white p-1.5" id="tg-team-native" name="invite" multiple size="6" aria-describedby="tg-team-hint">
    <option value="[email protected]" data-initials="AO" data-tone="#be185d" selected>Amara Okafor</option>
    <option value="[email protected]" data-initials="BS" data-tone="#0369a1">Bruno Silva</option>
    <option value="[email protected]" data-initials="CW" data-tone="#15803d" selected>Chen Wei</option>
    <option value="[email protected]" data-initials="DH" data-tone="#b45309">Dagny Holm</option>
    <option value="[email protected]" data-initials="EY" data-tone="#7c3aed">Elif Yılmaz</option>
    <option value="[email protected]" data-initials="FH" data-tone="#0f766e">Farah Haddad</option>
  </select>
  <div class="relative" data-ui hidden>
    <div class="flex min-h-[46px] cursor-text flex-wrap items-center gap-1.5 rounded-xl border border-tg-line bg-white px-2 py-1.5 shadow-[0_1px_2px_rgba(29,27,44,.06)] transition-[border-color,box-shadow] duration-150 hover:border-[#6a6780] focus-within:border-tg-accent focus-within:shadow-[0_0_0_4px_rgba(109,40,217,.25)] motion-reduce:transition-none" data-field>
      <ul class="flex flex-wrap gap-1.5 empty:hidden" data-chips aria-label="People to invite"></ul>
      <input class="h-8 min-w-0 flex-[1_0_100px] bg-transparent px-1.5 text-[.95rem] outline-none placeholder:text-tg-muted" id="tg-team-input" type="text" placeholder="Name or email" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="tg-team-list" aria-describedby="tg-team-hint">
    </div>
    <div class="absolute top-[calc(100%+6px)] right-0 left-0 z-30 rounded-xl border border-[#dcd8e6] bg-white p-1.5 shadow-[0_18px_40px_-12px_rgba(29,27,44,.3)]" data-popup hidden>
      <ul class="max-h-[250px] overflow-y-auto overscroll-contain" role="listbox" id="tg-team-list" aria-label="People" aria-multiselectable="true">
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-team-1" data-value="[email protected]"><span class="grid size-[30px] flex-none place-items-center rounded-full bg-(--tone,#6d28d9) text-[.72rem] font-bold tracking-[.02em] text-white" style="--tone:#be185d" aria-hidden="true">AO</span><span><span data-label>Amara Okafor</span> <span class="block text-[.8rem] font-normal text-tg-muted">[email protected]</span></span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-team-2" data-value="[email protected]"><span class="grid size-[30px] flex-none place-items-center rounded-full bg-(--tone,#6d28d9) text-[.72rem] font-bold tracking-[.02em] text-white" style="--tone:#0369a1" aria-hidden="true">BS</span><span><span data-label>Bruno Silva</span> <span class="block text-[.8rem] font-normal text-tg-muted">[email protected]</span></span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-team-3" data-value="[email protected]"><span class="grid size-[30px] flex-none place-items-center rounded-full bg-(--tone,#6d28d9) text-[.72rem] font-bold tracking-[.02em] text-white" style="--tone:#15803d" aria-hidden="true">CW</span><span><span data-label>Chen Wei</span> <span class="block text-[.8rem] font-normal text-tg-muted">[email protected]</span></span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-team-4" data-value="[email protected]"><span class="grid size-[30px] flex-none place-items-center rounded-full bg-(--tone,#6d28d9) text-[.72rem] font-bold tracking-[.02em] text-white" style="--tone:#b45309" aria-hidden="true">DH</span><span><span data-label>Dagny Holm</span> <span class="block text-[.8rem] font-normal text-tg-muted">[email protected]</span></span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-team-5" data-value="[email protected]"><span class="grid size-[30px] flex-none place-items-center rounded-full bg-(--tone,#6d28d9) text-[.72rem] font-bold tracking-[.02em] text-white" style="--tone:#7c3aed" aria-hidden="true">EY</span><span><span data-label>Elif Yılmaz</span> <span class="block text-[.8rem] font-normal text-tg-muted">[email protected]</span></span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
        <li class="group flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[.93rem] data-active:bg-tg-tint data-chosen:font-semibold aria-disabled:cursor-not-allowed aria-disabled:text-[#6f6c80] [&_mark]:bg-transparent [&_mark]:font-bold [&_mark]:text-tg-accent" role="option" id="tg-team-6" data-value="[email protected]"><span class="grid size-[30px] flex-none place-items-center rounded-full bg-(--tone,#6d28d9) text-[.72rem] font-bold tracking-[.02em] text-white" style="--tone:#0f766e" aria-hidden="true">FH</span><span><span data-label>Farah Haddad</span> <span class="block text-[.8rem] font-normal text-tg-muted">[email protected]</span></span><svg class="invisible ml-auto size-4 flex-none text-tg-accent group-data-chosen:visible" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="m3.5 8.5 3 3 6-7"/></svg></li>
      </ul>
      <p class="px-2.5 py-2 text-[.88rem] text-tg-muted" data-empty hidden>No one on the team matches. Type a full email address to invite someone new.</p>
      <p class="px-2.5 py-2 text-[.88rem] text-tg-muted" data-create hidden>Press Enter to invite <b class="text-tg-ink" data-new></b></p>
    </div>
    <template><li class="inline-flex h-[30px] items-center gap-[5px] rounded-full bg-tg-chip pr-1 pl-[3px] text-[.86rem] font-semibold whitespace-nowrap text-tg-chip-ink"><span class="grid size-6 flex-none place-items-center rounded-full bg-(--tone,#6d28d9) text-[.66rem] font-bold tracking-[.02em] text-white" data-avatar aria-hidden="true"></span><span data-text></span><button type="button" class="grid size-[22px] cursor-pointer place-items-center rounded-full hover:bg-[rgba(76,29,149,.14)] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-tg-accent [&_svg]:size-3"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="m4.5 4.5 7 7m0-7-7 7"/></svg></button></li></template>
  </div>
  <p class="text-[.82rem] text-tg-muted" id="tg-team-hint">Type a name, or any email address and press Enter or a comma.</p>
  <p class="sr-only" role="status"></p>
</div>
</div></div>
CSS
@theme {
  --color-tg-accent: #6d28d9;
  --color-tg-tint: #f1ecfe;
  --color-tg-chip: #ece6fd;
  --color-tg-chip-ink: #4c1d95;
  --color-tg-line: #8b88a0;
  --color-tg-ink: #1d1b2c;
  --color-tg-muted: #5c596e;
}
JavaScript
// Tag multiselect: an editable combobox (WAI-ARIA APG, list autocomplete) with a multi-select listbox.
// Typing filters the suggestions. Down / Up move through them (aria-activedescendant), Enter adds or removes the
// highlighted one, Escape closes the list (or clears the text when it is already closed), Backspace in an empty
// field removes the last chip, and every chip has its own remove button. A polite status line reads out changes.
// The <select multiple> holds the real form value and the starting picks; without JavaScript it stays visible.
// data-max="5" caps the number of picks; data-create="email" lets people add any email address.
(() => {
  document.querySelectorAll('[data-tags]').forEach(root => {
    const native = root.querySelector('select');
    const label = root.querySelector('label');
    const input = root.querySelector('[role="combobox"]');
    const popup = root.querySelector('[data-popup]');
    const list = root.querySelector('[role="listbox"]');
    const chips = root.querySelector('[data-chips]');
    const chip = root.querySelector('template').content.firstElementChild;
    const empty = root.querySelector('[data-empty]');
    const create = root.dataset.create && root.querySelector('[data-create]');
    const counter = root.querySelector('[data-counter]');
    const status = root.querySelector('[role="status"]');
    const max = +root.dataset.max || Infinity;
    const opts = [...list.querySelectorAll('[role="option"]')];
    const names = new Map(opts.map(o => [o, o.querySelector('[data-label]').textContent]));
    const source = v => [...native.options].find(n => n.value.toLowerCase() === v.toLowerCase());
    const isOpen = () => input.getAttribute('aria-expanded') === 'true';
    const isEmail = s => /^[^\s@,]+@[^\s@,]+\.[a-z]{2,}$/i.test(s);
    const canCreate = q => !!create && isEmail(q) && !source(q);
    const say = msg => { status.textContent = msg; };
    let active = null;

    native.hidden = true;
    root.querySelector('[data-ui]').hidden = false;
    label.htmlFor = input.id;

    function render() {
      const on = [...native.selectedOptions];
      chips.replaceChildren(...on.map(n => {
        const li = chip.cloneNode(true);
        li.querySelector('[data-text]').textContent = n.text;
        const av = li.querySelector('[data-avatar]');
        if (av) {
          av.textContent = n.dataset.initials || n.text.charAt(0).toUpperCase();
          if (n.dataset.tone) av.style.setProperty('--tone', n.dataset.tone);
        }
        const x = li.querySelector('button');
        x.dataset.value = n.value;
        x.setAttribute('aria-label', 'Remove ' + n.text);
        return li;
      }));
      const full = on.length >= max;
      opts.forEach(o => {
        const picked = source(o.dataset.value).selected;
        o.setAttribute('aria-selected', picked);
        o.toggleAttribute('data-chosen', picked);
        if (full && !picked) o.setAttribute('aria-disabled', 'true');
        else o.removeAttribute('aria-disabled');
      });
      if (counter) counter.textContent = on.length + ' / ' + max;
      root.toggleAttribute('data-full', full);
    }
    function filter() {
      const q = input.value.trim(), lq = q.toLowerCase();
      const hits = opts.filter(o => {
        const hit = o.textContent.toLowerCase().includes(lq);
        const el = o.querySelector('[data-label]'), name = names.get(o), at = lq ? name.toLowerCase().indexOf(lq) : -1;
        o.hidden = !hit;
        if (at < 0) el.textContent = name;
        else {
          const mark = document.createElement('mark');
          mark.textContent = name.slice(at, at + q.length);
          el.replaceChildren(name.slice(0, at), mark, name.slice(at + q.length));
        }
        return hit;
      });
      if (create) { create.hidden = !canCreate(q); create.querySelector('[data-new]').textContent = q; }
      if (empty) empty.hidden = hits.length > 0 || canCreate(q);
      return hits;
    }
    function setActive(o) {
      if (active) active.removeAttribute('data-active');
      active = o;
      if (!o) return input.removeAttribute('aria-activedescendant');
      o.setAttribute('data-active', '');
      input.setAttribute('aria-activedescendant', o.id);
      const r = o.getBoundingClientRect(), b = list.getBoundingClientRect(); // keep it in view without scrolling the page
      if (r.top < b.top) list.scrollTop -= b.top - r.top;
      else if (r.bottom > b.bottom) list.scrollTop += r.bottom - b.bottom;
    }
    function refresh(announce) {
      const hits = filter(), q = input.value.trim();
      setActive(null);
      popup.hidden = false;
      input.setAttribute('aria-expanded', 'true');
      if (announce) say(!q ? '' : hits.length ? hits.length + (hits.length === 1 ? ' suggestion' : ' suggestions')
        : canCreate(q) ? 'Press Enter to add ' + q : 'No matches');
    }
    function close() {
      popup.hidden = true;
      input.setAttribute('aria-expanded', 'false');
      setActive(null);
    }
    function changed(n, picked) {
      if (!picked && n.hasAttribute('data-new')) n.remove();
      render();
      native.dispatchEvent(new Event('change', { bubbles: true }));
      say(n.text + (picked ? ' added, ' : ' removed, ') + native.selectedOptions.length + ' selected');
    }
    function toggle(o) {
      const n = source(o.dataset.value);
      if (!n.selected && native.selectedOptions.length >= max) return say('You can pick up to ' + max);
      n.selected = !n.selected;
      changed(n, n.selected);
    }
    function add(q) {
      if (native.selectedOptions.length >= max) return say('You can pick up to ' + max);
      const n = new Option(q, q, false, true); // not a default, so a form reset drops it
      n.setAttribute('data-new', '');
      native.add(n);
      input.value = '';
      refresh(false);
      changed(n, true);
    }
    function clearText(keep) {
      if (!input.value) return;
      input.value = '';
      refresh(false);
      if (keep) setActive(keep);
    }

    input.addEventListener('keydown', e => {
      const q = input.value.trim();
      switch (e.key) {
        case 'ArrowDown': case 'ArrowUp': {
          e.preventDefault();
          if (!isOpen()) refresh(false);
          if (e.altKey) break;
          const vis = opts.filter(o => !o.hidden), i = vis.indexOf(active), down = e.key === 'ArrowDown';
          if (vis.length) setActive(vis[i < 0 ? (down ? 0 : vis.length - 1) : (i + (down ? 1 : -1) + vis.length) % vis.length]);
          break;
        }
        case 'Enter':
          if (isOpen() && active) { e.preventDefault(); const o = active; toggle(o); clearText(o); }
          else if (canCreate(q)) { e.preventDefault(); add(q); }
          else if (q) {
            e.preventDefault();
            const o = opts.find(o => names.get(o).toLowerCase() === q.toLowerCase());
            if (o) { toggle(o); clearText(); }
          }
          break;
        case ',':
          if (canCreate(q)) { e.preventDefault(); add(q); }
          break;
        case 'Escape':
          if (isOpen()) { e.preventDefault(); close(); }
          else if (input.value) { e.preventDefault(); input.value = ''; filter(); }
          break;
        case 'Backspace':
          if (!input.value && native.selectedOptions.length) {
            const n = native.selectedOptions[native.selectedOptions.length - 1];
            n.selected = false;
            changed(n, false);
          }
          break;
        case 'Tab': close(); break;
        case 'ArrowLeft': case 'ArrowRight': case 'Home': case 'End': setActive(null); break;
      }
    });
    input.addEventListener('input', () => refresh(true));
    input.addEventListener('click', () => { if (!isOpen()) refresh(false); });
    input.addEventListener('blur', close);
    root.querySelector('[data-field]').addEventListener('click', e => {
      if (e.target === input || e.target.closest('button')) return;
      input.focus();
      if (!isOpen()) refresh(false);
    });
    popup.addEventListener('mousedown', e => e.preventDefault()); // keep focus in the input
    list.addEventListener('click', e => {
      const o = e.target.closest('[role="option"]');
      if (!o) return;
      toggle(o);
      clearText(o);
      setActive(o);
      input.focus();
    });
    list.addEventListener('pointermove', e => {
      const o = e.target.closest('[role="option"]');
      if (o && o !== active) setActive(o);
    });
    chips.addEventListener('click', e => {
      const x = e.target.closest('button');
      if (!x) return;
      const n = source(x.dataset.value);
      n.selected = false;
      changed(n, false);
      input.focus();
    });
    document.addEventListener('pointerdown', e => { if (isOpen() && !root.contains(e.target)) close(); });
    if (native.form) native.form.addEventListener('reset', () => setTimeout(render));
    render();
  });
})();

About this set

Multiselects where each pick becomes a removable chip inside the field, next to a text input that filters the suggestions as you type. Chips with suggestions, up to five is a skills picker with a 3 / 5 counter: matching text is highlighted, picked skills carry a tick in the list, and once five are chosen the rest are marked unavailable and trying to add another is announced. Invite by name or email, with avatars suggests teammates with coloured initials and their addresses, and also accepts any valid email typed in, added with Enter or a comma. Both are built on the WAI-ARIA editable combobox with list autocomplete: Down and Up move through the suggestions with aria-activedescendant, Enter adds or removes the highlighted one, Escape closes the list or clears the text, and Backspace in an empty field removes the last chip. Every chip has its own labelled remove button, a polite status line reads out what was added or removed, and a hidden select multiple holds the value, so the form submits the picks. Without JavaScript that select is shown as a plain list.

What’s included

  • Removable chips with labelled remove buttons
  • Type-to-filter suggestions with the match highlighted
  • Optional limit with a live counter
  • Free entry of email addresses with Enter or a comma
  • Backspace removes the last chip
  • Hidden select multiple keeps the form value

Accessibility

The text input is a role="combobox" with aria-autocomplete="list", aria-expanded and aria-activedescendant, labelled by its visible label and described by the hint; the suggestions form a multi-select listbox with aria-selected, and options past the limit get aria-disabled. Each chip's remove button is named, for example Remove Figma, and a role="status" line announces additions, removals and match counts. Focus shows as a ring around the whole field, and the chip buttons have their own outline.

Customise it

Change --tg-accent, --tg-chip, --tg-chip-ink, --tg-tint and --tg-line on .tg; avatar colours come from data-tone on each option. In Tailwind edit the --color-tg-* theme colours. data-max sets the limit and data-create="email" allows free entry.