taga11y
taga11y
Accessible tagging, built in — not bolted on.
A zero-dependency multi-select / tag input with inline chips, built to the
WAI-ARIA APG combobox pattern exactly — aria-activedescendant keeps focus
on the input throughout dropdown navigation. Keyboard, switch-device, and
screen-reader support aren't features here; they're the product.
Out of the box it renders as close to a native form control as it gets, blending into any page untouched, yet it's fully customizable.
~8 KB gzipped · zero dependencies · vanilla JS · React · Vue
WAI-ARIA APG combobox · SR matrix: NVDA · JAWS · VoiceOver · TalkBack · axe-core + Chromium/Firefox/WebKit e2e
Try the live demos & read the docs
Features
- Accessible by default — full WAI-ARIA APG combobox pattern, screen reader announcements, keyboard and switch device support
- Progressive enhancement — the original
<input>is enhanced in place and works as a form control without JavaScript - Three suggestion modes — static arrays, pre-fetched async, and dynamic per-keystroke callbacks
- Whitelist mode — restrict to predefined suggestions with
enforceSuggestions: true - Configurable — delimiters, max tags, debounce, theming, custom serializers
- Dark mode — automatic (
prefers-color-scheme) or forced (data-theme) - CSS custom properties — retheme from ~6 base tokens; 9 derived tokens cascade automatically (per-token opt-out preserved)
- Native form integration — hidden input with serialised values, form reset support
- Zero dependencies — bundles to a single ESM/CJS/IIFE file via Vite
Installation
npm
npm install taga11y
Import the core class and the stylesheet:
import Taga11y from 'taga11y';
import 'taga11y/dist/taga11y.css';
CDN / IIFE
<script src="https://unpkg.com/taga11y/dist/taga11y.iife.js"></script>
<link rel="stylesheet" href="https://unpkg.com/taga11y/dist/taga11y.css">
<script>
const widget = new Taga11y(inputEl, {
suggestions: ['JavaScript', 'TypeScript'],
});
</script>
Also available on jsDelivr, often faster in Europe and Asia:
<script src="https://cdn.jsdelivr.net/npm/taga11y/dist/taga11y.iife.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/taga11y/dist/taga11y.css">
Quick start
<label for="tags">Select tags:</label>
<input id="tags" type="text">
const input = document.querySelector('#tags');
const widget = new Taga11y(input, {
suggestions: ['JavaScript', 'TypeScript', 'Rust', 'Go', 'Python'],
maxTags: 10,
delimiter: [',', 'Enter'],
});
Framework wrappers
First-party React and Vue components are shipped from subpath exports. Both
wrap the same vanilla Taga11y class, support controlled and uncontrolled
binding, forward every event, expose the underlying instance, and mount
client-only (SSR-safe). Import the stylesheet once, as with the core.
Peer dependencies: React 19 (
taga11y/react) or Vue 3 (taga11y/vue).
React
import { useState } from 'react';
import { Taga11yInput } from 'taga11y/react';
import 'taga11y/dist/taga11y.css';
function Example() {
const [tags, setTags] = useState(['JavaScript']);
return (
<Taga11yInput
value={tags} // controlled; omit and use defaultValue for uncontrolled
onChange={(values) => setTags(values)}
suggestions={['JavaScript', 'TypeScript', 'Rust']}
maxTags={5}
/>
);
}
Props: every Taga11yOptions key, plus value/defaultValue (string arrays),
the event callbacks onAdd/onRemove/onClear/onChange/onPaste/onDestroy,
and instanceRef to receive the live Taga11y instance. A standard ref
resolves to the underlying <input> element (use instanceRef for the
instance). Unknown props pass through to the underlying <input>.
With react-hook-form
Because onChange is value-first and ref resolves to the <input>, the
component drops into a <Controller> via {...field}:
import { useForm, Controller } from 'react-hook-form';
import { Taga11yInput } from 'taga11y/react';
function TagsField() {
const { control } = useForm({ defaultValues: { tags: [] } });
return (
<Controller
name="tags"
control={control}
render={({ field }) => (
<Taga11yInput {...field} suggestions={['JavaScript', 'TypeScript', 'Rust']} />
)}
/>
);
}
field.onChange receives the tag array on every mutation, and field.ref gets
the input node so focus-on-validation-error works.
Vue
<script setup>
import { ref } from 'vue';
import { Taga11yInput } from 'taga11y/vue';
import 'taga11y/dist/taga11y.css';
const tags = ref(['JavaScript']);
</script>
<template>
<Taga11yInput
v-model="tags"
:suggestions="['JavaScript', 'TypeScript', 'Rust']"
:max-tags="5"
/>
</template>
Use v-model (controlled) or defaultValue (uncontrolled). Emits add,
remove, clear, change, paste, destroy; the instance is available via a
template ref (expose()).
Notes —
onDestroy/destroyfire during unmount; your framework's own unmount hook is usually the better place for teardown. Thei18noption is init-only: change it by remounting, not by updating the prop.
Configuration
All options are optional.
| Option | Type | Default | Description |
|---|---|---|---|
suggestions |
SuggestionItem[] | { once } | { query } |
— | Suggestion source: static array, pre-fetched async ({ once: () => Promise<SuggestionItem[]> }), or dynamic per-keystroke ({ query: (input, signal) => Promise<SuggestionItem[]> }). Omit for free-text mode. |
maxTags |
number |
— | Maximum number of tags. No limit when omitted. |
maxSuggestions |
number |
10 |
Maximum suggestions rendered in the dropdown at once. 0 shows none; to show all matches pass a large number (e.g. 9999999). Negative values are clamped to 0. |
delimiter |
string | string[] |
[',', 'Enter'] |
Character(s) that commit the current input as a tag. 'Tab' is a valid value. |
enforceSuggestions |
boolean |
false |
When true, only suggestions from the source can be committed. Free-text is rejected. |
name |
string |
inherited from input.name |
name on the hidden form input. The original input's name is removed on init (prevents double submission) and restored on destroy(). Override only when you need a different name. |
label |
string |
— | Injects a <label> inside the widget wrapper. Only use when no page-level <label for="…"> exists — existing page labels work automatically via the preserved id. Do not combine both. |
disabled |
boolean |
false |
Renders the component in a disabled state. |
theme |
'dark' | 'light' | null |
null |
Forces a colour scheme. null follows prefers-color-scheme. |
serialize |
(tags: TagData[]) => string |
comma-join values | Custom serializer for the hidden input value. |
deserialize |
(raw: string) => SuggestionItem[] |
split on ,, trim, drop empties |
Custom parser for the original input's value on init and on form reset. Pair with serialize to round-trip a non-comma format (e.g. JSON). String items resolve labels from loaded suggestions; { label, value } items are used as-is. |
debounceMs |
number |
200 |
Debounce delay (ms) for dynamic suggestion requests. Negative values are clamped to 0. |
i18n |
{ locale: string; dir?: 'ltr' | 'rtl'; strings?: Partial<I18nStrings> } |
— | Internationalisation. locale is a BCP 47 tag; dir (optional) stamps direction on the wrapper, otherwise it cascades; strings partially overrides the built-in English map (missing keys fall back to English with a dev console.warn). Init-only — ignored by settings(); switch locale via destroy() + new Taga11y(). See the i18n guide. |
Events
The widget dispatches custom events on the original <input> element. All events bubble, are non-cancelable, and carry a detail payload. Use widget.on(name, handler) or inputEl.addEventListener(name, handler).
| Event | Detail | When fired |
|---|---|---|
taga11y:add |
{ tag: TagData } |
After a single tag is added (typing, suggestion select, addTag, addTags, or each chunk of a delimited paste). |
taga11y:remove |
{ tag: TagData } |
After a tag is removed (chip button, Backspace on empty input, or removeTag). |
taga11y:clear |
{ tags: TagData[] } |
After all tags are cleared via clearTags or setTags (fired before the new tags are added). |
taga11y:change |
{ tags: TagData[] } |
After any mutation, with the final tag set. Fired once per addTags / setTags / delimited paste, after all per-chunk taga11y:add events. |
taga11y:paste |
{ added: TagData[], skipped: string[] } |
Once after a paste gesture that contained a configured single-character delimiter. skipped lists chunks rejected (not in suggestion list in whitelist mode, already selected, or maxTags reached). Not fired for paste without a delimiter. |
taga11y:destroy |
{} |
After destroy() tears down the widget. |
See docs/guides/events.md for full per-event reference and examples.
API Docs
Full API documentation with all classes, methods, types, and options is generated by TypeDoc and available at the API reference.
- Taga11y class — constructor, methods, computed getters
- Types & interfaces —
TagData,Taga11yOptions,SuggestionsSource,SuggestionItem
Bundle size
Tiny, zero-dependency, and tree-shakeable. Gzipped:
| Artifact | Gzipped |
|---|---|
Core (taga11y.mjs) |
~8.0 KB |
Stylesheet (taga11y.css) |
~1.6 KB |
React wrapper (taga11y/react) |
~1.0 KB + core |
Vue wrapper (taga11y/vue) |
~1.0 KB + core |
The framework wrappers import the core rather than duplicating it, so a React or Vue app pays the wrapper cost once on top of the shared core.
Browser support
Modern evergreen browsers — the 2023+ baseline of Chrome/Edge 120+, Firefox 120+, and Safari 17+. No Internet Explorer.
The baseline is set by the CSS :dir() pseudo-class (used for RTL layout);
below it, RTL layout falls back to LTR and every other feature still works.
taga11y also relies on ResizeObserver, AbortController/AbortSignal,
Intl.PluralRules, crypto.getRandomValues, and the CSS inset property —
all available across this baseline.
The dropdown's viewport-flip (opening upward when clipped below the fold) is a
progressive enhancement built on CSS Anchor Positioning (anchor-name,
position-try-fallbacks), currently Chromium 125+. It's gated behind
@supports, so browsers without anchor positioning simply keep the standard
absolute-positioned dropdown — no functionality is lost.
Guides
- Theming — CSS custom properties, dark mode, custom themes
- Accessibility — ARIA pattern, keyboard navigation, AT compatibility
- Migration from Tagify / Choices.js — API mapping, DOM differences
- Custom Events —
taga11y:add,taga11y:remove,taga11y:clear,taga11y:change,taga11y:paste,taga11y:destroy - Limitations — Known limitations and design trade-offs
Contributing
Guides for contributors verifying and extending taga11y:
- Testing — four-layer test strategy (unit, integration, screen reader, modality)
- Screen Reader Verification — AT pairing matrix and manual steps
- Modality Checklist — keyboard, touch, voice, switch-device checklists
- PR Accessibility Checklist — a11y review checklist for pull requests
Examples
See the examples/ directory for runnable demos:
Run the examples locally:
npm run dev
- Basic ESM — ES Module inclusion
- Basic CJS — CommonJS inclusion
- Basic IIFE — Script tag inclusion
- Whitelist mode
- Custom delimiter
- Dynamic suggestions (mock API)
- Fetch error demo
- Pre-fetched suggestions
- Dropdown viewport flip
- Dark mode
- Multiple themes on one page
- Custom theme
- Internationalisation — Portuguese + Arabic RTL
- Disabled state
- Form integration
- Custom serializer & deserializer
- Programmatic API
- Accessibility / keyboard demo
- All custom events demo — live event log for
taga11y:add,taga11y:remove,taga11y:clear,taga11y:change,taga11y:paste,taga11y:destroy - Debug — keyboard & input event trace
- React — controlled (value / onChange)
- react-hook-form — Controller integration
- Vue — controlled (v-model)