ui-016

Tag Input

£10 or the whole Vault, £99

A tag input for forms: type, paste or pick chips that spring in, drag or Alt+Arrow to reorder, with a suggestions list and a mount() API.

intermediate
7 more details
micro-interactionform-animationspring-physicskeyboard-navigationdragreorderkeyboard-reorder

This element reads better at your own screen size than in the frame below:

Updated

About this component

A tag field that turns typed or pasted text into chips. Each chip springs in from zero width, so its neighbours slide across to make room, and collapses the same way when it is removed. Drag a chip with a mouse or a finger and the others slide aside across wrapped lines; focus one and Alt or Option with an arrow moves it instead, announced to screen readers. Backspace selects the last chip before it removes it, a pasted list becomes several chips in a short stagger, duplicates shake the chip that already holds the value, and an optional suggestions listbox filters as you type.

Read the full effect overview

A drop-in component: one stylesheet, one script, vanilla JavaScript and GSAP core, no framework and no plugins. TagInput.mount(el, options) takes suggestions, a limit, a validate function and a form name, returns add, remove, getTags, setTags and destroy, and fires add, remove and reorder events; a hidden input carries the ordered values into a normal form. Two themes, light and dark, switched with one data-variant attribute.

Tag Input - UI component preview

What's included

12 items
  • Enter, comma or a pasted list commits chips, with a stagger on paste
  • Chips spring in from zero width, so neighbours slide rather than jump
  • Removing a chip collapses its width and closes the gap
  • Drag to reorder with mouse or touch, neighbours sliding aside across wrapped lines
  • Alt or Option plus arrow reorders from the keyboard, announced
  • Backspace selects the last chip first, a second press removes it
  • Duplicate tags shake the existing chip instead of adding a copy
  • Suggestions filter as you type, with a combobox ARIA contract
  • Max count and a validate hook refuse entries with a shake and a message
  • TagInput.mount() API with add, remove, getTags, setTags, destroy and events
  • Hidden input carries the ordered values into a form submit
  • Light and dark themes on one set of custom properties

Perfect for

4 use cases
  • Skill, topic and interest pickers on profile forms
  • Filter and label builders in dashboards where order means priority
  • Recipient fields on invite and share dialogs that accept a pasted list
  • Keyword entry on upload and publishing screens

How it works

3 sections

Width springs, not opacity fades

A new chip is inserted, measured at its natural width, then set to zero width and tweened out to that measurement while its scale runs a separate back.out spring. Because the chip owns its right margin and the box model is border-box, the collapse to zero also swallows the padding, so the neighbouring chips slide instead of stepping.

Reorder as a FLIP on layout slots

Before a move the script records every chip's offsetLeft and offsetTop, moves the chip in the DOM, and plays each neighbour from its old slot to its new one with an x and y tween, so a chip that wraps to the next line slides there. The dragged chip follows the pointer from its own slot in whole pixels, swaps only once the pointer passes a neighbour's centre, and its move listeners sit on window because pointer capture is lost when an element moves in the DOM.

Keyboard first

The field is a native text input with real buttons for each remove control. Enter and comma commit, the first Backspace on an empty input selects the last chip and the second removes it, Left arrow walks into the chips, Delete removes the focused one and Alt or Option with an arrow moves it, with every change announced through a live region. With suggestions on, the input is a combobox that keeps focus and points at the highlighted option with aria-activedescendant.

Difficulty Intermediate
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this UI element's demo, from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Paid UI element

Purchase to unlock the code.

Buying Tag Input opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£10 Standard license, unlimited projects

Browse UI elements

Or the whole library: the Vault, £99, one payment.

Documentation

What's Included

  • index.html: the demo page and the markup to copy

  • assets/style.css: component styles, plain CSS custom properties for colours

  • assets/script.js: readable, commented source exposing one global, TagInput

  • Enter or comma commits the typed text as a chip

  • Pasting a, b, c or one tag per line creates several chips in a short stagger, skipping duplicates

  • Chips spring in from zero width, so neighbours slide rather than jump

  • Removing a chip collapses its width and closes the gap

  • Drag a chip with a mouse or a finger to reorder: it lifts, follows the pointer, the others slide aside across wrapped lines, and it lands on whole pixels

  • Keyboard reorder: focus a chip and press Alt/Option + Left or Right, announced to screen readers

  • Backspace in an empty input selects the last chip first; a second Backspace removes it

  • Left arrow from the input walks into the chips; Delete or Backspace removes the focused one

  • Duplicate tags shake the existing chip instead of adding a copy

  • Max count and your own validate refuse an entry with a shake and a short message

  • Suggestions filter as you type; arrows move through them and Enter picks

  • A hidden input carries the comma-separated value into a normal form submit

  • mount() API with add, remove, getTags, setTags, destroy and three events; any number of instances per page

Quick Start

1. Add to your HTML <head>:

Code snippet omitted: it ships with the download.

2. Add the markup. No ids are needed for behaviour; the one on the input is only for its <label>.

Code snippet omitted: it ships with the download.

.tag-box, .tag-chips and .tag-entry are required. .tag-suggest / .tag-suggest-list are optional: leave them out and the field is a plain chip input with no listbox. .tag-count and its two spans are optional too. The hidden input's value is the starting set of chips; leave the hidden input out and the script creates one.

3. Add before the closing </body> tag:

Code snippet omitted: it ships with the download.

With data-tag-input on the root, that is all: it mounts itself on DOMContentLoaded. To pass options from JavaScript, write data-tag-input="manual" and mount it yourself:

Code snippet omitted: it ships with the download.

Call mount() after DOMContentLoaded (or from a script placed after assets/script.js that waits for it).

API

TagInput.mount(element, options)

Returns an instance, or the existing one if element is already mounted.

Option Type Default What it does
suggestions string[] data-options The suggestions the listbox filters. Omit both and the listbox never opens
maxTags number data-max, else 6 The chip limit. Written to data-max, which is read at use time, so changing the attribute later moves the limit and the counter with no re-init. Infinity (or data-max="0") means no limit
validate (tag, tags) => boolean | string none Runs before a typed, pasted, picked or add()ed tag is committed. Return false to refuse, a string to refuse with that message (shown in place of the counter and announced), anything else to accept
name string data-name, else the hidden input's own name The form field name on the hidden input

Instance methods

Method What it does
add(tag) Adds a chip with the spring pop. Goes through the duplicate, limit and validate checks; returns true if it was added. Fires taginput:add
remove(tag) Removes a chip by value (case-insensitive) or index, with the width collapse. Returns true if one was removed. Fires taginput:remove
getTags() The current values, in order
setTags(tags) Replaces the whole list. This is the programmatic sync path, so it fires no add/remove events and skips validate; it still de-duplicates and respects the limit
destroy() Removes every listener, reverts every GSAP tween (gsap.context().revert()), removes the chips and the nodes the script added, and restores the attributes it set. The hidden input keeps its value, so mounting again restores the same chips

TagInput.get(element) returns an element's instance, TagInput.mountAll(scope) mounts every [data-tag-input] in a scope, and TagInput.destroyAll() tears every instance down.

Events

Dispatched on the root element; they bubble.

Event event.detail Fires when
taginput:add { tag, index, tags } A chip is added by typing, pasting, picking a suggestion or add()
taginput:remove { tag, index, tags } A chip is removed by its button, the keyboard or remove()
taginput:reorder { tag, from, to, tags } A drag drops a chip in a new place, or Alt/Option + Arrow moves one

tags is the full list after the change.

Form value

One hidden input carries every tag as a comma-separated string (Animation,Tutorial), updated on every add, remove and reorder, so a plain form post receives the tags in the order the user arranged them. Commas are separators on entry, so a tag can never contain one. If your server wants one field per tag instead, listen for the three events and write <input type="hidden" name="topics[]"> elements from event.detail.tags.

Themes

The field ships in two themes, light (the default) and dark: one design at two token values. Set the attribute on <body> in the demo, or on any ancestor of the component in your own page:

Code snippet omitted: it ships with the download.

Every colour, radius and shadow the component uses is a custom property in the body[data-variant="light"] and body[data-variant="dark"] blocks at the top of assets/style.css. The script never reads the variant name: the duplicate flash reads --chip-warn and --chip-bg with getComputedStyle at the moment it runs, so re-valuing a block is the whole of a rebrand.

The short list to change for your own brand: --accent, --accent-text and --accent-wash (the focus ring, the selected chip and the highlighted suggestion), --box-bg / --box-border (the field), --chip-bg / --chip-fg / --chip-border (the chips), --chip-warn (the duplicate flash) and --shadow (the lifted chip and the suggestions panel).

Demo furniture. The toolbar strip across the top of index.html, the .stage* ground it stands on and the "Article title" field beside it are the demo, not the component. None of it ships in assets/; delete it and copy the .tag-field block on its own.

How It Works

Width springs, not opacity fades. A new chip is inserted, measured at its natural width, then set to zero width and tweened out to that measurement while its scale runs a separate back.out(3) spring. The chip carries its own right margin and the box model is border-box, so collapsing to zero width also swallows the padding and the gap; that is what makes the neighbouring chips slide rather than step.

Reorder is a FLIP on layout slots. Before any move the script records every chip's offsetLeft / offsetTop, moves the chip in the DOM, reads the new slots and plays each neighbour from its old slot to its new one with an x/y tween. Offsets ignore transforms, so a chip already sliding is measured where it will land, and a move that wraps a chip onto the next line slides it diagonally instead of jumping. While dragging, the lifted chip is positioned from its own slot to the pointer every move (rounded to whole pixels), and a swap only happens once the pointer passes the centre of a neighbour in the direction of travel, so two chips of different widths cannot flip-flop. The drop tween snaps to whole pixels and clears the transform on landing.

Drag listeners live on window. Pointer capture is dropped the moment a captured element moves in the DOM, which would end the drag after one swap, so pointermove / pointerup / pointercancel are bound to window for the life of the gesture and removed after. A press that moves less than 4px (6px for touch) is not a drag: it hands focus to the input. Chips carry touch-action: none, so a finger that lands on a chip drags it rather than panning the page.

Backspace is two presses. On an empty input the first Backspace marks the last chip selected (accent outline, announced); the second removes it. Any other key, typing, or leaving the field clears the selection, so a held Backspace never eats a row of chips.

Paste splits. A paste containing a comma or a line break is split, trimmed and committed one tag at a time with a 70ms stagger. Each goes through the same checks as typing, so a duplicate shakes its existing chip and the limit stops the run.

Suggestions. The listbox filters the suggestions against what is typed and hides anything already held. It fades in over 0.16s and never steals focus: the input keeps it and points at the highlighted option with aria-activedescendant, which is what a combobox owes a screen reader.

Keyboard and ARIA contract

In the input:

Key What it does
Enter / , Commits the highlighted suggestion if there is one, otherwise the typed text
Backspace (empty input) First press selects the last chip, second press removes it
Left arrow (caret at the start) Moves focus to the last chip
Arrow Down Opens the suggestions, then moves down the list (wraps)
Arrow Up Moves up the list (wraps)
Escape Closes the suggestions; the typed text is left alone
Tab Closes the suggestions and moves on normally

On a focused chip (or its remove button):

Key What it does
Left / Right arrow Previous / next chip; Right from the last chip returns to the input
Alt/Option + Left / Right Moves the chip one place and keeps focus on it; announced as "moved to position 2 of 4"
Backspace Removes it; focus moves to the previous chip, else the next, else the input
Delete Removes it; focus moves to the next chip, else the input
Home / End First chip / the input
Escape Back to the input
Enter / Space on the remove button Removes it and returns focus to the input

The input is role="combobox" with aria-autocomplete="list", aria-expanded, aria-controls and aria-activedescendant (set by the script when a listbox is present); the list is role="listbox" with role="option" children carrying aria-selected. The chips container is role="list" and each chip a focusable role="listitem" named by its value and described by a visually hidden hint ("Press Delete to remove. Alt or Option with Left or Right arrow moves it."). Each remove control is a real <button> labelled Remove <value>, still reachable with Tab. Adds, removals, selection, moves and refusals are announced through a polite live region the script adds. The counter is wired to the input with aria-describedby.

Customisation

  • Colours, radii and borders live in assets/style.css as custom properties per theme.
  • Chip entry timing is the gsap.fromTo pair in commit(): power3.out on width, back.out(3) on scale. Lower the back overshoot for a calmer pop.
  • Removal timing is the gsap.to in remove(). Below about 0.25s the collapse reads as a jump cut rather than a slide.
  • The neighbours' slide during a reorder is the tween in flip() (0.3s power3.out); the lift is scale: 1.05 in onDragMove() and the landing the tween in endDrag().
  • The paste stagger is the 0.07 in commitMany().
  • The shake is elastic.out(1, 0.28) on x. Raise the second number for fewer wobbles.
  • Never add a CSS transform to .tag-chip: GSAP owns its scale, x and y, and a CSS transform underneath is parsed as a pixel offset that never clears.

Upgrading from the id-based version

Earlier versions found their parts by id (tagField, tagBox, tagChips, tagEntry, tagValue, tagCountNum, tagCountMax, tagSuggest, tagSuggestList) and exposed only window.__thumbnail. That markup still works: a #tagField without data-tag-input mounts itself and each id is used as a fallback for its class. Two behaviours changed: Backspace on an empty input now selects the last chip before removing it, and the global is TagInput instead of __thumbnail. For new work, use the classes above so several fields can share a page.

Accessibility

  • Native <input type="text"> and native <button> remove controls, so Tab order and screen reader semantics come for free; chips are extra arrow-key stops, not extra Tab stops.
  • :focus-visible rings on chips, chip buttons and the toolbar, and the field ring changes on focus.
  • Drag has a full keyboard equivalent (Alt/Option + Arrow), so reordering never depends on a pointer.
  • prefers-reduced-motion: reduce skips every tween: chips appear, disappear and reorder instantly, pasted tags land at once, a dragged chip still follows the pointer but drops without a flight, the suggestions layer does not fade, and the shake does not run. It is read at use time, so changing the OS setting takes effect without a reload.

Requirements

  • GSAP 3.12+ (core only, no plugins)
  • No build step, no framework

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.