Skip to content

Components

React Tag Inputs — the Keyboard Minefield Hiding in a "Simple" Component

Enter, Comma, Paste and the Two-Stage Backspace — All Four or It Feels Broken
Written by Aiden Wood Founder and Lead Design Engineer
🏷️ ⌨️ ✂️

A tag input is a text field that turns strings into chips. Fifteen minutes of work, right? Then someone pastes design, engineering, video and gets one tag. Someone else hits backspace and silently deletes a tag they couldn't see was selected. The component that demos in a day takes a week to stop feeling broken.

Velocity X's standard-tag-input ships the week version: commit keys, paste splitting, two-stage backspace, duplicate rejection — one React file you can read and restyle.

The Money Pattern

All the behaviour lives in one keydown handler plus a paste handler:

const commit = (raw: string) => {
  const t = raw.trim().toLowerCase();
  if (t && !tags.includes(t)) setTags([...tags, t]);
  setDraft('');
};

const onKeyDown = (e: KeyboardEvent) => {
  if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); commit(draft); }
  if (e.key === 'Backspace' && draft === '') {
    e.preventDefault();
    // Two-stage: first backspace SELECTS the last chip, second deletes it
    if (pending) { setTags(tags.slice(0, -1)); setPending(false); }
    else setPending(true);
  } else if (pending) setPending(false);
};

const onPaste = (e: ClipboardEvent) => {
  e.preventDefault();
  e.clipboardData.getData('text').split(/[,\n]/).forEach(commit);
};

The two-stage backspace is the detail users can't name but always feel: deleting data that's outside the text cursor needs a visible warning state (the last chip highlights), not an instant deletion. It's the same reason your terminal asks before closing a running process.

The Catch

Duplicate policy is a product decision wearing a technical hat. Case-insensitive rejection ("Design" === "design") is right for topic tags and wrong for case-sensitive identifiers — so it belongs in a prop, not buried in the commit function. Same for the separator set: emails want ; and whitespace, topics want commas.

And the chips need to be reachable: each delete button is a real <button aria-label={`Remove ${tag}`}>, not a styled span with an onClick — keyboard users remove tags too.

The Verdict

Tag inputs are the textbook case of "simple UI, deep behaviour". Commit keys, paste splitting, staged backspace, prop-driven duplicate policy — that's the actual spec. The finished file is in Velocity X's Forms category, source you own, beside the OTP input and wizard that round out the hard-input set.

Let us make some quick suggestions?

Please provide your full name.
Please provide your phone number.
Please provide a valid phone number.
Please provide your email address.
Please provide a valid email address.
Please provide your brand name or website.
Please provide your brand name or website.