A tag input that feels like a real product

A tag input that feels like a real product Set markodenic.tech as your preferred Google source

Creem.io

Merchant of record payments for SaaS and digital products, handling checkout, subscriptions, taxes, invoices, and global payment operations.

Get started today

The field says “Enter tags, separated by commas”. A user types design, ux and it works. The next one types design; ux, or design ux, or pastes three email addresses from a spreadsheet with newlines between them, and your form saves all of it as one tag.

The fix is not better parsing hints. It is turning each value into a visible chip the moment it is committed, so the user can see what the field understood.

The fix

html
<div class="tags" id="tags">
  <input id="tag-input" placeholder="Add a tag" autocomplete="off">
  <input type="hidden" name="tags" id="tags-value">
</div>
javascript
const tags = [];

input.addEventListener('keydown', (event) => {
  if (event.key === 'Enter' || event.key === ',') {
    event.preventDefault();
    add(input.value);
    input.value = '';
  }

  if (event.key === 'Backspace' && !input.value) {
    tags.pop();
    render();
  }
});

function add(value) {
  const tag = value.trim();
  if (!tag || tags.some(t => t.toLowerCase() === tag.toLowerCase())) return;

  tags.push(tag);
  render();
}

Enter or comma commits. Backspace on an empty field deletes the last chip. Duplicates are silently ignored, because a user adding a tag twice meant to add it once.

Paste is the case that decides it

This is where every homegrown tag field falls down, and it is where the real users are: people copying a column of email addresses out of a spreadsheet.

javascript
input.addEventListener('paste', (event) => {
  event.preventDefault();
  event.clipboardData
    .getData('text')
    .split(/[,;\n\t]+/)
    .forEach(add);
});

Splitting on commas, semicolons, newlines, and tabs covers a spreadsheet column, a comma list from an email client, and a semicolon list from Outlook. `add()` already trims and skips empties, so the trailing newline costs nothing.

Paste one value with no separator in it and it lands as one tag. The behavior does not change, it just stopped being wrong for everyone else.

Nothing submitted yet.

Paste design, ux; research or a column copied from a spreadsheet. Type a tag and click Submit without pressing Enter: blur commits it, so it still gets sent.

The bug that loses data

A user types the last tag, does not press Enter, and clicks Submit. The tag is sitting in the input, uncommitted, and it never reaches your server. They watch the form succeed and assume it saved.

javascript
input.addEventListener('blur', () => {
  add(input.value);
  input.value = '';
});

Clicking Submit blurs the field first, so the pending text is committed before the form goes. Four lines, and it is the difference between a control that works and one that quietly drops the last thing the user typed.

Making it submit like a normal field

The chips are divs, so the form knows nothing about them. Keep one hidden input in sync:

javascript
function render() {
  list.replaceChildren(...tags.map(chip));
  hidden.value = tags.join(',');
}

Your backend receives tags=design,ux from a plain form post and does not need to know any of this happened. If your framework prefers an array, write one hidden input per tag with name="tags[]" instead.

A hidden input is the least known of the input types, and this is exactly what it is for.

replaceChildren swaps the whole list in one operation, so there is no diffing and no stale event listener left behind.

The parts that make it feel finished

A real remove button per chip. <button type="button" aria-label="Remove design">, not a span with a click handler. It is keyboard reachable and it announces what it does. And type="button", or clicking the X submits your form.

Click the container, focus the input. The whole bordered box looks like a text field, so it should behave like one:

javascript
container.addEventListener('click', () => input.focus());

Style the container, not the input. The input inside gets border: 0; outline: 0; flex: 1, and the wrapper gets the border and :focus-within ring. Otherwise you have a box inside a box.

Announce the change. A role="status" region saying “design added, 3 tags” tells a screen reader user what the chips are telling everyone else.

Where this comes up

  • Recipient fields: the pasted-from-a-spreadsheet case, at its most painful.
  • Tags and labels: posts, tasks, files, anything with a taxonomy.
  • Filters: each active filter as a chip with its own remove button, synced to the URL so the view is shareable.
  • Skills and interests: onboarding forms where users type freely.

Why this you want this

  • The user can see what you parsed: a chip is confirmation, a comma-separated string is a guess.
  • Removing one item stops being surgery: no clicking into the middle of a string to delete a word and the comma next to it.
  • It posts like a text field: one hidden input, and nothing about your form handling changes.

Find the comma-separated field in your app and paste a column of values from a spreadsheet into it. Whatever happens is what your users have been living with.

Happy coding!

Marko

Rate this issue

Your ratings shape which issues I recommend to you.