Tag Input
Free9 premium multi-select tag-input variants: default, avatar, grouped, creatable, colors, token limit, async search, inline and overflow.
import { SvTagInputComponent } from '@styloviz/tag-input';Demo & usage
Basic usage
Usage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Compact size
<sv-tag-input [options]="options" [(values)]="tags" size="sm" />
Async search (Pro)
queryChange is debounced (asyncDelay); fetch results and feed them back through [asyncOptions]. Async search ships in the Pro package — the tag is `sv-tag-input-pro`.
<sv-tag-input-pro variant="async-search" [(values)]="tags" [asyncOptions]="results" (queryChange)="search($event)" />
Reactive forms (formControlName)
Implements ControlValueAccessor, so it binds to reactive and template-driven forms. The control value is a string[]; formControl.disable() disables the field.
// form = new FormGroup({ tags: new FormControl<string[]>(['ng', 'ts']) }); <sv-tag-input formControlName="tags" [options]="options" label="Skills" /> <!-- or template-driven --> <sv-tag-input [(ngModel)]="tags" [options]="options" />
When to use
- Capturing multiple free-form or suggested tokens (tags, recipients, keywords).
- You want creatable entries, token limits or async suggestions.
- For a single choice from a list, use Select Dropdown or Autocomplete.
Async states
None — this component has nothing to load, so it has no loading, empty or error state to drive.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| values | string[] (two-way) | [] | Current tags (two-way). |
| options | TagOption[] | [] | Suggestion options offered as the user types. |
| compareWith | ((a: string, b: string) => boolean) | null | null | What "the same tag" means. Defaults to strict equality on `value`. ⚠️ FREE, NOT PRO. `check:parity` flagged it as an undeclared Pro-only member and was right to: knowing whether two values are the same tag is correctness, not a premium feature, and a free control that silently duplicates a selection is broken rather than merely basic. |
| size | TagInputSize | 'md' | Field size: sm, md or lg. |
| label | string | '' | Field label. |
| placeholder | string | 'Add tags…' | Placeholder shown in the input. |
| hint | string | '' | Helper text shown below the field. |
| errorMessage | string | '' | Error message; sets the field to an error state when present. |
| serverError | string | { readonly message: string } | null | null | A validation failure reported by the **server** — the 422 that comes back after submit, not something the client could have known. It is a separate input from `errorMessage` because it has a different lifetime, not because it needs a second way to show a string. A server error is a statement about *the value that was submitted*: the moment the user edits the field it stops being true, and this input expires itself rather than sitting there telling someone their new email is already taken. `errorMessage` has no such expiry — it is the host's to clear. While live it outranks `errorMessage`: it is the more specific fact, and it is the one the user has not seen yet. Typed structurally rather than as `SvDataError` so this stays dependency free — an `SvDataError` satisfies it unchanged. |
| disabled | boolean | false | Disable the field. |
| required | boolean | false | Mark the field as required. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| tagsChange | TagInputChangeEvent | Emitted with the change (added/removed tag + full list). |
| valuesChange | string[] | Emits the new `values` whenever it changes — the output half of `[(values)]`. |
<sv-tag-input-pro>
Also in this family — SvTagInputProComponent from @styloviz-pro/tag-input.
Async states
- loading
- error
- retry
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| values | string[] (two-way) | [] | Two-way bound array of selected tag values. |
| options | TagOption[] | [] | Flat option list (used by all variants except grouped). |
| compareWith | ((a: string, b: string) => boolean) | null | null | What "the same tag" means. Defaults to strict equality on `value`. The defect this closes: IDENTITY WAS TWO DIFFERENT THINGS IN THE SAME COMPONENT. Selection compares `o.value === v`; `canCreate` compares lowercased LABELS. So a host whose ids differ only in case, or whose backend returns `'U1'` where the form holds `'u1'`, got a control that would neither de-duplicate nor resolve — and said nothing about either. ⚠️ IT COMPARES VALUES, NOT OPTIONS. The value is what the form holds and what a server sends back; the option may not exist yet when a value arrives (see `unresolvedTags`). A comparator over options would be uncallable in exactly the case that needs it. |
| groups | TagGroup[] | [] | Grouped option list (used by grouped variant). |
| variant | TagInputVariant | 'default' | Visual variant — controls chip rendering and dropdown layout. |
| size | TagInputSize | 'md' | Field size. |
| label | string | '' | Visible label rendered above the field (hidden for inline variant). |
| placeholder | string | 'Add tags…' | Placeholder shown in the search input when no tags are selected. |
| hint | string | '' | Helper text rendered below the field. |
| errorMessage | string | '' | Validation error message — shown in red below the field. |
| serverError | string | { readonly message: string } | null | null | A validation failure reported by the **server** — the 422 that comes back after submit, not something the client could have known. It is a separate input from `errorMessage` because it has a different lifetime, not because it needs a second way to show a string. A server error is a statement about *the value that was submitted*: the moment the user edits the field it stops being true, and this input expires itself rather than sitting there telling someone their new email is already taken. `errorMessage` has no such expiry — it is the host's to clear. While live it outranks `errorMessage`: it is the more specific fact, and it is the one the user has not seen yet. Typed structurally rather than as `SvDataError` so this stays dependency free — an `SvDataError` satisfies it unchanged. |
| disabled | boolean | false | Disables all interactions. |
| required | boolean | false | Shows a required asterisk next to the label. |
| maxTags | number | 0 | Maximum number of tags that can be selected (0 = unlimited). The token-limit variant displays a visual progress counter. |
| asyncDelay | number | 400 | Debounce delay in ms before `queryChange` is emitted for async-search. The parent component is expected to populate `asyncOptions` in response. |
| asyncOptions | TagOption[] | [] | Options fed by the parent for the async-search variant. Update this whenever you receive results for the current query. |
| debounceMs | number | 0 | Delay before `queryChange` fires, in ms. Spelled the same way as every other picker in the kit; falls back to `asyncDelay` when that is set. |
| serverSide | boolean | false | Fetch options from the server in **any** variant, not just `async-search`. The supplied options are shown exactly as given. |
| minChars | number | 0 | Characters required before a lookup is sent. Below this the panel says so instead of showing a misleading empty list. |
| loading | boolean | false | True while the host is fetching. Merged with the component's own in-flight state, so a real backend and the built-in delay render identically. |
| stale | boolean | false | The results on screen answer an **older** query — a newer request is still in flight. Distinct from `loading`, which cannot separate the two cases it covers: the first fetch (nothing on screen — show a skeleton) and a refinement of results already visible (keep them, and say they are provisional). Blanking the panel on the second is the most jarring thing a picker can do; showing the old list with no indication is a quiet lie. Bind `svSuggestController(...).stale` from `@styloviz/data`, or `createRequestGuard().stale` from `@styloviz/core`. The results dim and report `aria-busy` while staying fully interactive. |
| error | SvDataError | string | null | null | Failure loading options, rendered inside the panel. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| tagsChange | TagInputChangeEvent | Fires every time the selection changes. |
| retry | void | The host should re-run the failed option load. |
| queryChange | string | Fires (debounced) on every keystroke in the async-search variant. The parent should fetch results and set `[asyncOptions]` accordingly. |
| valuesChange | string[] | Emits the new `values` whenever it changes — the output half of `[(values)]`. |
Customization & theming
- Nine `variant`s (default, with-avatar, grouped, creatable, with-colors, token-limit, async-search, inline, overflow) in three `size`s.
- Provide `options` or `groups`; cap with `maxTags`; drive async suggestions with `asyncOptions` + `asyncDelay`; add `label`, `hint`, `errorMessage`, `required`.
- Two-way bind `[(values)]` (string[]) or use a reactive control (CVA); react to `tagsChange` and `queryChange`. Fixed-position dropdown escapes overflow containers.
Best practices
- Do: Offer suggestions via `options` so users reuse existing tags instead of inventing duplicates.
- Do: Support Enter to add and Backspace to remove the last token.
- Do: Bind with `formControlName` / `[(ngModel)]` for validation and dirty/touched tracking; `[(values)]` remains available for lightweight use.
- Don't: Don't allow unbounded tokens where a limit makes sense — cap them with `maxTags`.
- Do: Set `compareWith` when your ids are not byte-identical across the wire — a backend that returns `U1` where the form holds `u1` otherwise gets a control that neither de-duplicates nor resolves, and says nothing about either.
- Do: Give options a `sublabel` when two of them can share a label. Two people called Alex Chen render as two identical chips otherwise, and removing one is a coin flip; with a sublabel the component adds it — but only to the chips that actually clash.
- Do: Check `unresolvedTags()` after your options load. A selected value with no matching option is kept (dropping it would delete the user's choice because a list refreshed) and marked, so the chip shows as unknown instead of displaying a database id as though it were a name.
- Do: Read `pasteReport()` if you need to react to a partial paste. Pasting 200 addresses into a limit of 50 adds 50 and reports 150 with reasons — over the limit, already in the list, matched more than one option, or not recognised — announced in a `role="status"` line. Silently taking 50 would look like it worked.
Accessibility
- Correct ARIA combobox: `role="combobox"` sits on the `<input>` with `aria-expanded`, `aria-haspopup`, `aria-controls` and `aria-activedescendant`; the popup is a `role="listbox"` of `role="option"` rows (headers, loading and "no results" are presentational).
- All option ids are generated per instance, so multiple tag fields on one page never collide; the input is named via `label` → `placeholder`.
- Keyboard: arrows highlight, Enter adds, Backspace removes the last token; every chip exposes a labelled Remove button.
- Implements ControlValueAccessor — works with `[(ngModel)]`, `[formControl]` and `formControlName`; `formControl.disable()` disables the field.
Feedback on Tag Input
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.