TagInput
Token input β type to add tags, Backspace to remove, with paste-to-split and dedupe.
Live demo
01Basic Β· bindable value
Type and press Enter or comma. Backspace on the empty box removes the last chip.
- Svelte
- TypeScript
value = ["Svelte", "TypeScript"]
Capped Β· max five
Adds are refused once five tags exist; try pasting x, y, z, w, v, u.
- design
- a11y
Validated Β· slug format only
Only lowercase slugs pass validate(); "Hero Banner" is rejected, "hero-banner" is fine.
- hero-banner
Case-sensitive dedupe
With caseSensitive, "JS" and "js" are treated as distinct tags.
- JS
- CSS
Disabled
Read-only state β no editing or removal.
- locked
- archived
Implementation
02<script lang="ts">
import TagInput from '$lib/components/TagInput.svelte';
let tags = $state<string[]>(['svelte', 'typescript']);
</script>
β
<TagInput bind:value={tags} placeholder="Add a tagβ¦" max={8} />TagInput wraps a chip list and a text input in a single role="group". Enter or comma commits the draft through one tryAdd() gate that trims, dedupes (case-insensitive by default), checks the max cap, and runs your optional validate(tag). Backspace on an empty input arms then removes the last chip β a two-step gesture that protects fast typists. Pasting a comma- or newline-separated list splits and adds each piece, and every rejection is announced through a polite aria-live region.
Logic explainer
03What Does It Do? (Plain English)
TagInput turns a plain text box into a row of removable "chips" (also called tokens or pills). You type a word and press Enter or comma, and it becomes a chip. Keep typing to add more. Made a mistake? Press Backspace while the box is empty to peel off the last chip, or click the little β on any chip.
Think of it like: the "To:" field in an email client β each recipient becomes its own little badge you can remove individually, and pasting a long list of addresses splits them out automatically.
The committed list is a plain string[] exposed through bind:value, so the parent always holds the current tags.
How It Works (Pseudo-Code)
STATE:
value[] β the committed tags (bindable)
draft β the text currently being typed
armedForDelete β has the first empty-Backspace primed a deletion?
WHEN a key is pressed in the text box:
IF key is Enter OR comma:
commit the draft as a chip, then clear the draft
ELSE IF key is Backspace AND draft is empty AND there are chips:
IF already armed β remove the LAST chip
ELSE β arm deletion (highlight the last chip red)
ELSE:
cancel any armed deletion
WHEN text is pasted:
IF the pasted text contains a comma or newline:
split on commas/newlines β add each piece (running the same checks)
TO ADD a candidate tag:
trim it
REJECT if empty, over the max count, a duplicate, or validate() says no
OTHERWISE append it to value[]The Core Concept: Two-Step Backspace Deletion
NaΓ―ve token inputs delete the last chip on the very first Backspace. That bites users who type fast and overshoot β a stray Backspace silently destroys a finished tag.
TagInput uses a two-step "arm then fire" gesture instead:
draft empty, press Backspace βββΆ ARMED (last chip turns red)
ARMED, press Backspace βββΆ DELETED (chip removed, disarmed)
ARMED, press any other key ββΆ DISARMED (nothing lost)The armed state is purely visual feedback driven by class:is-armed, so the user always sees which chip is about to go before it goes.
Paste-to-Split and Dedupe
Pasting is where token inputs earn their keep. The paste handler only intercepts the event when the clipboard text actually contains a delimiter (/[\n,]/), so pasting a single value still behaves like normal typing. Multi-value pastes are split, trimmed, emptied-filtered, and each piece runs through the same tryAdd gate as a typed tag.
Dedupe is centralised in one key() function:
key(tag) = caseSensitive ? tag.trim() : tag.trim().toLowerCase()Every existence check compares keys, so "Svelte" and "svelte" collapse to one entry by default, but flipping caseSensitive keeps them distinct without touching any other logic.
Validation and Capacity
A single tryAdd chokepoint returns a human-readable reason string (or '' on success). That reason is mirrored into a visually hidden aria-live="polite" region so screen-reader users hear why an entry was refused β duplicate, invalid, or over the max cap. When the cap is hit the text input is disabled and a visible hint appears, so the limit is obvious to sighted and non-sighted users alike.
State Flow Diagram
βββββββββββββββββββββββ
β IDLE β
β draft typingβ¦ β
ββββββββββββ¬βββββββββββ
β
βββββββββββββββββββββΌββββββββββββββββββββ
β Enter / comma β Backspace (empty) β paste w/ delimiter
βΌ βΌ βΌ
βββββββββββββ βββββββββββββ βββββββββββββ
β tryAdd() β β ARMED β β addMany() β
βββββββ¬ββββββ βββββββ¬ββββββ βββββββ¬ββββββ
β β Backspace β
ok βββ€ββ rejected βΌ β
β β βββββββββββββ β
βΌ βΌ β remove β βΌ
value[] notice β last chip β value[] (many)
updated announced βββββββββββββProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
value |
string[] (bindable) |
[] |
The committed tags; flows both ways via bind:value. |
placeholder |
string |
'Add a tagβ¦' |
Placeholder for the text input (hidden when at capacity). |
max |
number |
Infinity |
Maximum number of tags allowed. |
caseSensitive |
boolean |
false |
When false, differently-cased duplicates collapse to one. |
disabled |
boolean |
false |
Disable the whole control. |
ariaLabel |
string |
'Tags' |
Accessible name for the group and the add-input. |
validate |
(tag: string) => boolean |
() => true |
Return false to reject a candidate tag. |
Edge Cases
| Situation | Behaviour |
|---|---|
| Submit empty/whitespace draft | Ignored β no chip created, no notice. |
| Duplicate (case-insensitive) | Rejected; polite announcement "β¦ is already added". |
Duplicate with caseSensitive |
Allowed if the casing differs. |
max reached |
Input disabled, visible hint shown, further adds rejected. |
| Paste of a single value | Falls through to normal typing (no split). |
| Paste with trailing/empty fields | Empty pieces filtered out before adding. |
| Backspace mid-typing | Only deletes characters; chip deletion requires an empty draft. |
validate rejects |
Tag not added; reason announced via aria-live. |
| Remove via β button | Removes that specific chip and refocuses the input. |
Dependencies
Zero external dependencies. Pure Svelte 5 runes ($state, $derived, $bindable, $props), scoped CSS, and one inline SVG glyph for the remove button.
File Structure
TagInput.svelte # The component
TagInput.test.ts # Unit tests
TagInput.md # This explainerAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
value | string[] (bindable) | [] | The committed tags; flows both ways via bind:value. |
placeholder | string | 'Add a tagβ¦' | Placeholder for the text input (hidden at capacity). |
max | number | Infinity | Maximum number of tags allowed. |
caseSensitive | boolean | false | Treat differently-cased tags as distinct when deduping. |
disabled | boolean | false | Disable the whole control. |
ariaLabel | string | 'Tags' | Accessible name for the group and the add-input. |
validate | (tag: string) => boolean | () => true | Return false to reject a candidate tag. |