Building a tag input, and rereading it a year later
·3 min read

Building a tag input, and rereading it a year later

I wanted a tag input that looked at home in a shadcn form: type to find an option, select it, and see it as a pill inside the field. I also wanted the form to own the selected values, rather than having to pull them out of the component later.

The component is small. Rereading it still turned up a bug I'd shipped in the public registry, along with a few decisions I would keep.

Letting the form own the tags

The parent supplies tags and receives changes through setTags. The component keeps the search text and menu state locally. That makes it easier to use inside a form with its own validation and reset behaviour.

TypeScript
type Tag<T> = {
label: string
value: T
}

The label is the text people see and search. The value can be an ID or another application value. This implementation uses the label for identity when filtering, selecting and removing, so it expects labels to be unique. An app with two identically named options would need to change that or disambiguate them.

The searchable menu uses cmdk through the project's command components. Selected options disappear from the list, and the selection handler checks for a duplicate again before adding one.

The little interactions

Clicking the container focuses the input. Typing opens the menu. Clicking a pill removes it. The clear button clears the query when there is text, and clears the tags otherwise. AllTagsLabel lets a caller render an icon or avatar in a row without adding a separate option for every kind of decoration.

Backspace removes the last tag when the query is empty:

TSX
const handleBackSpace = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Backspace" && inputValue === "") {
event.preventDefault();
event.stopPropagation();
setTags(tags.slice(0, tags.length - 1));
}
};

I hadn't added a check for an empty tag list. Calling slice(0, -1) on an empty array returns another empty array, so nothing crashes. It still calls the parent's change handler when there was nothing to remove. That's unnecessary work, and an interaction test would make it straightforward to catch.

The brackets I missed

This is the wrapper as it appeared in the source:

TSX
<div className={(cn("space-y-2"), className)} ref={container} {...props}>

The comma operator evaluates the first expression and returns the second. That throws away the result of cn("space-y-2"); I meant to pass className into the same function call.

The layout still had other spacing, which helped the mistake go unnoticed. More awkwardly, the same line was in public/registry/tag-input.json. Anyone installing that version received a copy of it. Fixing my repository wouldn't update the copy already in their project.

I also applied the incoming className to both the wrapper and the inner Command. I'd give those separate responsibilities if I revised the component, so styling one doesn't unexpectedly style both.

What I'd keep, and what needs another pass

I like the controlled API and the way the selected tags sit beside the input. They make the component easy to fit into a form. The custom row renderer is useful too.

The repository didn't have tests when I reviewed it. Before distributing another version, I'd check the installed registry output as well as the demo: selection, duplicate prevention, empty Backspace, clearing and class placement. For a component people copy into their own code, that installed file is part of what I'm asking them to trust.