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
- Paste the snippet just before
</head>. - Wrap a text input in an element with
data-tag-input. - 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-tags | Starting tags, comma-separated. |
data-max | The most tags allowed. Past it the field shakes. 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, and stops as soon as you use it. |
data-words | What 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.
<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><div data-tag-input data-tags="Design,Motion" data-name="tags">
<input type="text" placeholder="Add a tag" aria-label="Tags">
</div>