Tag input
Multiple discrete values on one field — cost centers tagged on a record, several recipients on an approval-routing rule. No native element covers this (unlike file/date/segmented), so it's real JS — the same class as Combobox. The framework owns removal (it's deleting its own rendered chip); addition is yours: Enter dispatches an event with the typed text, you decide whether to accept it. Not for a small fixed set — three or four known options read better as checkboxes in a form field, and if only ONE value is allowed it is a Combobox.
Basic — cost centers
Type a value and press Enter to add it; click a tag's × (or press Backspace in the empty field) to remove the last one. Try it — this demo rejects exact duplicates, same as a real consumer would.
Approval-routing recipients
Empty
The container works with zero tags — no special empty-state markup needed.
What happens when a tag is removed
Removing a chip with the keyboard used to leave focus
on <body> — the reader lost their place in the form.
Three rules, in this order:
bo:tag-removefires first, from the chip, while it is still in the document — so a listener can read its position or siblings — and it bubbles, carryingdetail.value.- Then the chip is removed.
- Then focus moves to that same group's
.bo-tag-input__field— but only if the chip being removed contained focus. Removing a chip the user was not on never moves them.
The field is the destination because it already
exists in the markup contract and is where the next value goes, so no new
attribute, option or roving tab order is introduced. If your own
bo:tag-remove listener moves focus somewhere else, that wins
— the framework checks and does not override a deliberate move.
The status sentence is yours.
The framework moves focus; it does not announce the removal. If removing a
tag should be spoken (“CC-4021 removed, 2 remaining”), write it
into your own live
region from your bo:tag-remove listener — the same
“framework does visuals, you do the data” split as adding a
tag. What is verified here is where focus lands and the order of the three
steps above, in a real browser with real key presses; whether a screen
reader speaks it is a separate question these checks do not answer.
Markup
<!-- The framework owns REMOVAL (it's the framework's own rendered
chip) but not ADDITION: Enter dispatches bo:tag-add with the typed
text and clears the field — YOUR code validates/dedupes and appends
the actual .bo-tag-input__tag markup if accepted. -->
<div class="bo-tag-input" role="group" aria-label="Cost centers">
<span class="bo-tag-input__tag">CC-4021
<button class="bo-tag-input__remove" type="button" aria-label="Remove CC-4021">×</button>
</span>
<input class="bo-tag-input__field" type="text" aria-label="Add a cost center" placeholder="Add a cost center…">
</div>
<script type="module">
import { initTagInput } from "@busy-office/ui/js";
initTagInput();
document.addEventListener("bo:tag-add", (e) => {
const group = e.target.closest(".bo-tag-input");
// Your validation/dedup goes here — this demo just rejects blanks
// (already guarded by the behavior) and exact duplicates.
const existing = [...group.querySelectorAll(".bo-tag-input__tag")]
.map((t) => t.firstChild.textContent.trim());
if (existing.includes(e.detail.value)) return;
const tag = document.createElement("span");
tag.className = "bo-tag-input__tag";
tag.append(e.detail.value + " ");
const remove = document.createElement("button");
remove.className = "bo-tag-input__remove";
remove.type = "button";
remove.setAttribute("aria-label", `Remove ${e.detail.value}`);
remove.textContent = "×";
tag.append(remove);
group.querySelector(".bo-tag-input__field").before(tag);
});
</script> Quick reference generated
| Class | Role |
|---|---|
| .bo-tag-input | block (root) |
| .bo-tag-input--seamless | variant: seamless |
| .bo-tag-input__field | part: field |
| .bo-tag-input__remove | part: remove |
| .bo-tag-input__tag | part: tag |
API generated from CSS
| Blocks | bo-tag-input |
|---|---|
| Parts | bo-tag-input__fieldbo-tag-input__removebo-tag-input__tag |
| Variants | bo-tag-input--seamless |
| JS required | Required: initTagInput() — without it, Enter/Backspace do nothing and remove buttons don't remove their tag. |
- The framework owns REMOVAL only — a remove-button click or Backspace-on-empty deletes the framework's own rendered `.bo-tag-input__tag` and dispatches `bo:tag-remove`. Addition is yours: Enter dispatches `bo:tag-add` with the typed text and clears the field; validate/dedupe and append the tag markup yourself.
- Keyboard access to remove buttons is plain Tab order — they're real
buttonelements, no reimplemented roving-tabindex needed. - Wrap the group in `role="group"` with an `aria-label` naming what the tags represent (cost centers, recipients) — a native group has no accessible name of its own.
- Works with zero tags — no special empty-state markup.
Contrast — 27 AA pairs this component uses (generated, CI-gated)
| Text | on | Light | Dark |
|---|---|---|---|
| text-primary | bg-surface | 17.74 | 16.15 |
| text-primary | bg-canvas | 16.98 | 18.08 |
| text-secondary | bg-surface | 10.31 | 11.46 |
| text-secondary | bg-muted | 9.37 | 9.75 |
| text-muted | bg-surface | 7.56 | 6.65 |
| text-muted | bg-muted | 6.87 | 5.66 |
| bg-surface | text-primary | 17.74 | 16.15 |
| danger-text | bg-surface | 6.47 | 8.89 |
| success-text | bg-surface | 5.02 | 12.02 |
| accent | bg-surface | 5.47 | 9.07 |
| text-primary | bg-hover | 16.12 | 13.75 |
| text-primary | bg-selected | 17.01 | 11.86 |
| text-secondary | bg-selected | 9.88 | 8.41 |
| text-primary | bg-surface-raised | 17.74 | 14.51 |
| text-secondary | bg-canvas | 9.86 | 12.82 |
| border-control | bg-surface | 4.83 | 6.65 |
| --bo-state-error-color | bg-surface | 4.83 | 6.1 |
| border-control | bg-hover | 4.39 | 5.66 |
| border-control | bg-muted | 4.39 | 5.66 |
| focus-ring | bg-canvas | 3.58 | 5.05 |
| accent | bg-muted | 4.97 | 7.72 |
| warning-strong | bg-muted | 4.56 | 6.69 |
| warning-strong | bg-surface | 5.02 | 7.86 |
| danger | bg-muted | 4.39 | 5.19 |
| text-primary | bg-muted | 16.12 | 13.75 |
| success-text | bg-muted | 4.56 | 10.23 |
| danger-text | bg-muted | 5.88 | 7.57 |
Maturity generated
- Introduced
- v0.3.0
- CSS floor
- Chrome/Edge 99 · Firefox 97 · Safari 15.4
- Alignment scored
- 2026-08-21
- Assistive-tech evidence
- None recorded
Pin this component's first release with npm i @busy-office/ui@0.3.0.
The floor is derived from this component's stylesheet alone — set by
@layer — so it sits at or below the
whole framework's Chrome/Edge 119 · Firefox 129 · Safari 17.5.
No assistive-technology runtime result is recorded for any component: it needs a human listening to VoiceOver and NVDA on owner hardware; no gate in this repo can simulate announcement behaviour.
What automation covers instead is test:axe across every page at 1440 and 390, and check:forced-colors — see
accessibility.
Design-system alignment generated
100% (18/18) — how closely this component follows the design language. Each rule is scored 0-3 against a checkable citation; rules that don't apply leave the denominator. Scored 2026-08-21.
| Rule | Score | Evidence |
|---|---|---|
| Typography | 3 / 3 | no raw font-size |
| Colour | 3 / 3 | zero raw hex; forced-colors handled with a real 1px CanvasText border |
| Spacing | 3 / 3 | chip hug, 1rem remove box and 6rem field floor all carry their reason, incl. the 2.5.8 spacing-exception route (94.2) |
| Interaction | 3 / 3 | bubbling intent events (bo:tag-add) leave chip DATA with the consumer — framework does visuals |
| Content | 3 / 3 | the opener names two wrong-choice contexts and both alternatives: checkboxes for a small fixed set, Combobox when only one value is allowed (94.8) |
| Fit | 3 / 3 | multi-value entry; distinct from combobox's single-select, and the pages cross-name each other |