npm.io
1.0.0 • Published yesterday

taga11y

Licence
MIT
Version
1.0.0
Deps
0
Size
280 kB
Vulns
0
Weekly
0

taga11y

npm version minzipped size zero dependencies license: MIT

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()).

NotesonDestroy/destroy fire during unmount; your framework's own unmount hook is usually the better place for teardown. The i18n option 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.

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

Contributing

Guides for contributors verifying and extending taga11y:

Examples

See the examples/ directory for runnable demos:

Run the examples locally:

npm run dev

License

MIT

Keywords