← Forms

Tag Input

Type a word and press Enter (or a comma) and it pops into a tag with a springy bounce, the field making room as it grows. Click a tag’s cross, or press Backspace twice in the empty field, and it shrinks away. Type one that’s already there and that tag gives a little shake instead.

Press Enter or type a comma to add a tag. Backspace twice removes the last one.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap a text input in an element with data-tag-input.
  3. Give the wrapper a width: the tags wrap onto new lines and the field grows to fit.

Options

Add any of these attributes to the same element.

data-tagsStarting tags, comma-separated.
data-maxThe most tags allowed. Past it the field shakes. Default no limit.
data-nameAdds a hidden input with this name holding the tags, comma-separated, so they post with a form.
data-colorTag colour. Default #6a0dad.
data-autoplayIt types, adds and removes tags by itself while in view, and stops as soon as you use it.
data-wordsWhat autoplay types, comma-separated.

Each cross is a real button labelled “Remove …”, and additions and removals are read out politely. Listen for the change event: e.detail.tags is the list of tags.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Tag Input: a field that turns what you type into tags. Press Enter (or
    // type a comma) and the tag pops in with a springy bounce while the field
    // makes room; click its cross, or press Backspace twice in the empty
    // field, and it shrinks away. Adding a tag that's already there gives the
    // existing one a little shake instead.
    // Usage: <div data-tag-input><input type="text" placeholder="Add a tag" aria-label="Tags"></div>
    // Optional: data-tags (starting tags, comma-separated), data-max (most
    // tags allowed, default no limit), data-name (adds a hidden input with
    // this name holding the tags, comma-separated, so they post with a form),
    // data-color (tag colour, default #6a0dad), data-autoplay (it types,
    // adds and removes tags by itself while in view; it stops as soon as you
    // use it), data-words (what autoplay types, comma-separated).
    // Listen for the "change" event: e.detail.tags is the array of tags.
    var DEFAULTS = { color: '#6a0dad', words: 'Motion,Forms,CSS,Typography,Colour' };

    var X = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" aria-hidden="true"><path d="M7 7l10 10M17 7L7 17"/></svg>';

    var css =
      '.tgi{display:flex;flex-wrap:wrap;align-items:center;box-sizing:border-box;min-height:2.9em;padding:.25em .3em .25em .45em;' +
      'border:1.5px solid #dcdce1;border-radius:.8em;background:#fff;color:#18181b;text-align:left;cursor:text;transition:border-color .2s,box-shadow .2s}' +
      '.tgi-focus{border-color:var(--tgi-color);box-shadow:0 0 0 3px var(--tgi-soft)}' +
      '.tgi-tag{display:inline-flex;align-items:center;flex:none;box-sizing:border-box;height:1.9em;margin:.2em .35em .2em 0;padding:0 .25em 0 .75em;' +
      'border-radius:1em;background:var(--tgi-soft);color:var(--tgi-color);font-size:.9em;font-weight:600;white-space:nowrap;overflow:hidden;transition:background-color .2s,color .2s}' +
      '.tgi-armed{background:var(--tgi-color);color:#fff}' +
      '.tgi-x{display:grid;place-items:center;flex:none;width:1.35em;height:1.35em;margin:0 0 0 .3em;padding:0;border:0;border-radius:50%;' +
      'background:transparent;color:inherit;font:inherit;cursor:pointer;transition:background-color .2s}' +
      '.tgi-x svg{width:.8em;height:.8em}' +
      '.tgi-x:hover,.tgi-x:focus-visible,.tgi-press{background:var(--tgi-soft2);outline:none}' +
      '.tgi input,.tgi-entry{flex:1 1 5em;min-width:5em;box-sizing:border-box;height:1.9em;margin:.2em 0;padding:0 .2em;border:0;outline:none;' +
      'background:none;color:inherit;font:inherit;line-height:1.9em;white-space:nowrap;overflow:hidden}' +
      '.tgi input::placeholder,.tgi-ph{color:#8a8a93}' +
      '.tgi-caret{display:inline-block;width:1.5px;height:1.1em;margin-left:1px;vertical-align:-.15em;background:var(--tgi-color);animation:tgi-blink 1s steps(1) infinite}' +
      '@keyframes tgi-blink{50%{opacity:0}}' +
      '.tgi-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }
    function rgba(hex, a) {
      var m = /^#?([0-9a-f]{6})$/i.exec(hex);
      if (!m) return 'rgba(106,13,173,' + a + ')';
      var n = parseInt(m[1], 16);
      return 'rgba(' + (n >> 16) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
    }
    function list(s) { return (s || '').split(',').map(function (w) { return w.trim(); }).filter(Boolean); }

    function setup(el) {
      var d = el.dataset;
      var input = el.querySelector('input');
      var mock = !input;
      var color = d.color || DEFAULTS.color;
      var max = parseInt(d.max, 10) || Infinity;
      el.classList.add('tgi');
      el.style.setProperty('--tgi-color', color);
      el.style.setProperty('--tgi-soft', rgba(color, 0.1));
      el.style.setProperty('--tgi-soft2', rgba(color, 0.18));

      var entry, typed = '', placeholder = 'Add a tag';
      if (mock) {
        el.textContent = '';
        entry = document.createElement('span');
        entry.className = 'tgi-entry';
        el.appendChild(entry);
      } else {
        entry = input;
        input.setAttribute('autocomplete', 'off');
        placeholder = input.placeholder || placeholder;
      }
      function drawEntry(focus) {
        if (!mock) return;
        entry.innerHTML = typed ? '' : (focus ? '' : '<span class="tgi-ph">' + placeholder + '</span>');
        if (typed) entry.appendChild(document.createTextNode(typed));
        if (focus) entry.insertAdjacentHTML('beforeend', '<span class="tgi-caret"></span>');
      }
      drawEntry(false);

      var hidden = null, live = null;
      if (!mock) {
        if (d.name) {
          hidden = document.createElement('input');
          hidden.type = 'hidden';
          hidden.name = d.name;
          el.appendChild(hidden);
        }
        live = document.createElement('span');
        live.className = 'tgi-sr';
        live.setAttribute('aria-live', 'polite');
        el.appendChild(live);
      }

      var tags = [];
      function values() { return tags.map(function (t) { return t.text; }); }
      function changed(msg) {
        if (hidden) hidden.value = values().join(',');
        if (live) live.textContent = msg;
        if (!mock) el.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { tags: values() } }));
      }
      function shake(target) {
        if (reduced()) return;
        anime.animate(target, { x: [0, -5, 5, -3, 3, 0], duration: 380, ease: 'out(2)' });
      }

      function add(text, quiet) {
        text = text.replace(/,/g, '').trim();
        if (!text) return false;
        for (var i = 0; i < tags.length; i++) {
          if (tags[i].text.toLowerCase() === text.toLowerCase()) { shake(tags[i].el); return false; }
        }
        if (tags.length >= max) { shake(el); return false; }
        disarm();
        var t = { text: text, el: document.createElement('span') };
        t.el.className = 'tgi-tag';
        var label = document.createElement('span');
        label.textContent = text;
        var x = document.createElement(mock ? 'span' : 'button');
        x.className = 'tgi-x';
        x.innerHTML = X;
        if (!mock) {
          x.type = 'button';
          x.setAttribute('aria-label', 'Remove ' + text);
          x.addEventListener('click', function (e) { e.stopPropagation(); stop(); remove(t); if (input) input.focus(); });
        }
        t.el.appendChild(label);
        t.el.appendChild(x);
        t.x = x;
        el.insertBefore(t.el, entry);
        tags.push(t);
        if (!quiet && !reduced()) {
          var w = t.el.offsetWidth;
          anime.animate(t.el, { width: [0, w], marginRight: ['0em', '.35em'], duration: 260, ease: 'out(3)', onComplete: function () { t.el.style.width = ''; } });
          anime.animate(t.el, { scale: [0.2, 1], opacity: [0, 1], duration: 620, ease: 'outElastic(1, .55)' });
        }
        if (!quiet) changed(text + ' added');
        return true;
      }

      function remove(t) {
        var i = tags.indexOf(t);
        if (i < 0) return;
        tags.splice(i, 1);
        changed(t.text + ' removed');
        if (reduced()) { t.el.remove(); return; }
        anime.utils.remove(t.el);
        t.el.style.pointerEvents = 'none';
        anime.animate(t.el, {
          width: [t.el.offsetWidth, 0], paddingLeft: 0, paddingRight: 0, marginRight: 0, scale: 0.4, opacity: 0,
          duration: 300, ease: 'in(2)', onComplete: function () { t.el.remove(); }
        });
      }

      var armed = null;
      function disarm() { if (armed) { armed.el.classList.remove('tgi-armed'); armed = null; } }
      function arm(t) {
        disarm();
        armed = t;
        t.el.classList.add('tgi-armed');
        if (!reduced()) anime.animate(t.el, { scale: [0.9, 1], duration: 300, ease: 'out(3)' });
      }

      list(d.tags).forEach(function (w) { add(w, true); });
      if (hidden) hidden.value = values().join(',');

      if (!mock) {
        el.addEventListener('pointerdown', function (e) {
          stop();
          if (e.target === el) { e.preventDefault(); input.focus(); }
        });
        input.addEventListener('focus', function () { el.classList.add('tgi-focus'); });
        input.addEventListener('blur', function () { el.classList.remove('tgi-focus'); disarm(); });
        input.addEventListener('keydown', function (e) {
          if (e.isTrusted) stop();
          if (e.key === 'Enter' || e.key === ',') {
            e.preventDefault();
            if (add(input.value)) input.value = '';
          } else if (e.key === 'Backspace' && !input.value && tags.length) {
            var last = tags[tags.length - 1];
            if (armed === last) { disarm(); remove(last); } else arm(last);
          } else if (e.key !== 'Shift') disarm();
        });
        input.addEventListener('input', function () {
          disarm();
          if (input.value.indexOf(',') >= 0) {
            var parts = input.value.split(',');
            input.value = parts.pop();
            parts.forEach(function (p) { add(p); });
          }
        });
      }

      // Autoplay: types a word, presses Enter, and clears out the oldest tags.
      var words = list(d.words || DEFAULTS.words), stopped = false, timer = null, wi = 0, keep = Math.max(2, tags.length + 1);
      function stop() {
        if (stopped) return;
        stopped = true;
        clearTimeout(timer);
        if (mock) drawEntry(false);
        else el.classList.remove('tgi-focus');
      }
      function setTyped(s) {
        typed = s;
        if (mock) drawEntry(true);
        else input.value = s;
      }
      function later(fn, ms) { timer = setTimeout(function () { if (!stopped) fn(); }, ms); }
      function typeWord(w, i, done) {
        if (i > w.length) { done(); return; }
        setTyped(w.slice(0, i));
        later(function () { typeWord(w, i + 1, done); }, 70 + Math.random() * 70);
      }
      function cycle() {
        var w = words[wi++ % words.length];
        if (values().indexOf(w) >= 0) { later(cycle, 10); return; }
        el.classList.add('tgi-focus');
        if (mock) drawEntry(true);
        typeWord(w, 1, function () {
          later(function () {
            add(w);
            setTyped('');
            later(trim, 1100);
          }, 380);
        });
      }
      function trim() {
        if (tags.length <= keep) { later(cycle, 500); return; }
        // Alternate between clicking a cross and pressing Backspace twice.
        if (wi % 2) {
          var t = tags[0];
          t.x.classList.add('tgi-press');
          later(function () { remove(t); later(trim, 650); }, 420);
        } else {
          var last = tags[tags.length - 1];
          arm(last);
          later(function () { disarm(); remove(last); later(trim, 650); }, 700);
        }
      }
      if (on(d.autoplay) && !reduced() && words.length) {
        new IntersectionObserver(function (entries) {
          if (stopped) return;
          clearTimeout(timer);
          if (entries[0].isIntersecting) later(cycle, 600);
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-tag-input]').forEach(function (el) {
        if (el._tgi) return;
        el._tgi = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-tag-input data-tags="Design,Motion" data-name="tags">
  <input type="text" placeholder="Add a tag" aria-label="Tags">
</div>