registry:ui · Data Entry
tags-input — input × multiselect
Input + multiselect hybrid: flex-wrap tag chips with press-physics removal, Enter/comma/Tab/paste-split to add, Backspace removes last, suggestions popover with filtering, maxTags limit, duplicate flash, $bindable Tag[]. name= submits the tag VALUES as one JSON array string (empty set contributes nothing) through the faceless form-field bridge. Floating-surface law: ::after shadow layer (semi-transparent black/white) + @starting-style entry; variant='solid' | 'acrylic' | 'auto' (acrylic falls back to solid under prefers-reduced-transparency; the terminal bezel keeps its identity through the jx-surface fill props).
$npx jixoai-ui add tags-input
https://ui.jixoai.com/r/tags-input.json
overview
Overview
Input × multiselect: a keyed chip list and a combobox input in one shell — commit paths on real keys, duplicates flashed not added, announcements through the combobox contract, and a faceless form bridge carrying the values into FormData.
The shell is a chip host at hit height and a typing input in the same row. Commit
paths are real keys: Enter / comma / Tab (via blur) commit a chip — a pasted a,b,c splits into one commit per part — and
Backspace on an empty input deletes the last removable chip. Duplicates are flashed,
not added (200ms primary border + shake, reduced motion keeps the border flash only);
the typed text resolving to a suggestion commits the suggestion's value; maxTags hides
the input at the cap behind an N/N readout.
The two law-heaviest surfaces meet here. The KEYED LIST: chips render from a keyed each whose key is the composite value#index — duplicate tag VALUES cannot collide, the LAW #18 worst case (constant add/remove/reorder) stays hydration-safe by construction. The COMBOBOX: the typing input is role=combobox over the suggestion popover — aria-expanded live, the roving highlight riding aria-activedescendant + aria-owns with focus never entering the panel, committed suggestions flipping their rows' aria-selected. The chip host itself is a horizontal listbox of options; every × announces "remove (label)".
Values reach FormData through the faceless jx-form-field bridge — ONE JSON array
string under the field name (lossless when a tag value contains a comma). The eight
axes: density is the ONE consumed axis (the field's metrics ARE the kernel lanes —
measured hit-height flip), size and color are consumed at the passthrough boundary
(§1: the native element never receives them), theme rides the ROOT-PINNED alias inks
(frozen under a scoped dark island, re-derived at root-level dark), and
shape/radius/elevation/motion supply unread. Kinship: input (the single-value shell), select (the single-select popover law spin-off), combobox (the full law this popover miniaturizes).
tags-input
Input × multiselect: Enter / comma / Tab commits chips, Backspace on empty deletes the last, duplicates flash the existing chip, maxTags swaps the input for an “N/N tags” readout.
<TagsInput
label="stack"
suggestions={tagSuggestions}
bind:tags
/>tags-input
The high-form input — the shell becomes a chip host
The shell becomes a flex-wrap chip host where Enter / comma / Tab commits a tag, Backspace on empty deletes the last chip, maxTags swaps the input for an “N/N tags” readout, and duplicates flash the existing chip (primary border + shake) instead of adding. The panel is the same popover=auto terminal bezel as Select — light dismiss, Escape, and top layer are the browser's; focus never leaves the text field.
Type in the first one: matching suggestions (label or value contains, case-insensitive) pop under the
shell with ↑/↓ + Enter; type svelte again to see the
duplicate flash on the existing chip. Enter / comma / Tab commits chips directly —
pasting rust, ffi splits into two — and Backspace on
an empty input deletes the last removable chip.
svelte, typescriptat least one label is required
The component keeps the popover orchestration law of the family: popover="auto" panels anchored with CSS Anchor
Positioning (anchor-size(width), flip fallbacks,
viewport-center when the engine lacks it), focus that never enters the panel — the
roving highlight rides aria-activedescendant + aria-owns off the input itself — and geometry from
logical properties only, so dir="rtl" mirrors the
chip order with zero branches.
const stack = $state<Tag[]>([{ value: 'svelte' }]);
const stackSuggestions: Tag[] = [
{ value: 'svelte' }, { value: 'typescript' }, { value: 'node' },
];
<!-- Enter / comma / Tab commits a chip; Backspace on empty deletes the
last; typing filters the suggestion popover (↑/↓ + Enter) -->
<TagsInput label="stack" bind:tags={stack} suggestions={stackSuggestions} />
<!-- maxTags hides the input at the cap ("N/N tags"); duplicates flash the
existing chip instead of adding; removable={false} pins a chip -->
<TagsInput label="targets" bind:tags={targets} maxTags={3} />geometry
RTL — geometry from logical properties
Nothing in the component branches on direction: the chips wrap in logical flow and the suggestion panel anchors with logical offsets. The writing mode does the rest.
tags-input · rtl
The chip host wraps in logical flow, the selected-row edge is border-inline-start, and the panel anchors with CSS
Anchor Positioning whose offsets are logical too. The writing mode does the rest.
<script lang="ts">
import TagsInput, { type Tag } from '@ui/tags-input.svelte';
</script>
<!-- nothing branches on direction: chips wrap in logical flow, the
panel anchors with logical offsets -->
<div dir="rtl" class="flex flex-col gap-4 border-border border p-4">
<TagsInput label="stack (rtl)" bind:tags maxTags={4} suggestions={[
{ value: 'svelte' }, { value: 'typescript' }, { value: 'node' },
{ value: 'bun' }, { value: 'deno' }, { value: 'rust' },
]} />
<span class="text-muted-foreground text-[12px]">
dir="rtl" — chips right-first, panel edge inline-start
</span>
</div>types
TagsInput variants
The suggestion-backed host, the maxTags-capped field, a pinned (non-removable) chip, and the error state.
tags-input · variants
at least one label is required
<script lang="ts">
import TagsInput from '@ui/tags-input.svelte';
const tagSuggestions = [
{ value: 'svelte' }, { value: 'typescript' }, { value: 'node' },
{ value: 'bun' }, { value: 'deno' }, { value: 'rust' },
{ value: 'ffi' }, { value: 'conpty' }, { value: 'websocket' },
];
</script>
<div class="grid gap-4 sm:grid-cols-2">
<div class="border border-border p-4">
<TagsInput label="with suggestions" tags={[{ value: 'svelte' }]} suggestions={tagSuggestions} />
</div>
<div class="border border-border p-4">
<TagsInput label="maxTags 2 (capped)" tags={[{ value: 'node' }, { value: 'bun' }]} maxTags={2} />
</div>
<div class="border border-border p-4">
<TagsInput label="pinned chip" tags={[{ value: 'owner', removable: false }, { value: 'release' }]} />
</div>
<div class="border border-border p-4">
<TagsInput label="error" tags={[]} error="at least one label is required" />
</div>
</div>usage
Usage
Bind the Tag[] set; suggestions, the cap, and duplicate policy are plain props.
const stack = $state<Tag[]>([{ value: 'svelte' }]);
const stackSuggestions: Tag[] = [
{ value: 'svelte' }, { value: 'typescript' }, { value: 'node' },
];
<!-- Enter / comma / Tab commits a chip; Backspace on empty deletes the
last; typing filters the suggestion popover (↑/↓ + Enter) -->
<TagsInput label="stack" bind:tags={stack} suggestions={stackSuggestions} />
<!-- maxTags hides the input at the cap ("N/N tags"); duplicates flash the
existing chip instead of adding; removable={false} pins a chip -->
<TagsInput label="targets" bind:tags={targets} maxTags={3} />theming
Density and tokens
The shell, chips, and suggestion rows share the density-scope rhythm; resize the scope and the whole field follows.
| Token | Default | Source |
|---|---|---|
| --jx-hit | 28 / 32 / 40 / 48px | density |
| --jx-row-min | 28 / 32 / 40 / 48px | density |
| --jx-text | 11 / 12 / 13 / 15px | density |
| --jx-inset | 8 / 8 / 12 / 16px | density |
| --jx-gap | 8 / 8 / 12 / 16px | density |
api
API
Props extend the native HTML input attributes on the typing input; chips reach FormData through the faceless jx-form-field bridge as one JSON array of values.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
tags bind | Tag[] | [] | The committed tag set; bind:tags is the two-way contract. |
| suggestions | Tag[] | [] | Filtered into the popover while typing (label-or-value contains, case-insensitive). |
| name | string | — | Form field name — the bridge submits the tag values as one JSON array string. |
| placeholder | string | 'Add tag...' | Input placeholder while empty. |
| label | string | — | Field label rendered as label[for] above the control. |
| error | string | — | Error text: sets aria-invalid, wires aria-describedby, dashes the shell. |
| maxTags | number | — | Cap on the tag count; at the cap the input hides ("N/N tags"). |
| allowDuplicates | boolean | false | Allow the same value twice; false flashes the existing chip instead. |
| disabled | boolean | false | Disables the input and every chip × (entry guards back the buttons). |
| variant | 'solid' | 'acrylic' | 'auto' | 'auto' · Own default, not ambient | Floating-surface fill of the suggestion panel. Defaults: literal slot — own ’auto’, ambient when an axis opens. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
Tag
| Property | Type | Default | Description |
|---|---|---|---|
| value * | string | — | The committed tag identity — duplicates compare on this. |
| label | string | value | Display text. |
| removable | boolean | true | Hides the × button; Backspace skips it too. |
axes
The eight axes on tags-input
Density is the ONE consumed axis — the field's metrics ARE the kernel lanes (--jx-hit/--jx-row-min/--jx-text/--jx-gap/--jx-inset), so rungs rescale the whole field. size and color are consumed at the passthrough boundary (§1: the native element never receives them), the inks ride the ROOT-PINNED alias chains (frozen under a scoped dark island, re-derived at root-level dark — measured), the family owns the duplicate flash + shake and the panel's WAAPI entry, and shape/radius/elevation carry the supply chain.
| Property | Type | Default | Description |
|---|---|---|---|
| density | '2xs' | 'xs' | 'sm' | 'default' | 'lg' | 'auto' | number (+ the five legacy spellings) | 'auto' | MANAGED AND CONSUMED — the field's metrics ARE the kernel lanes: the shell min-height rides --jx-hit, chips ride --jx-row-min, text rides --jx-text, the rhythm rides --jx-gap/--jx-inset (measured: the seat shell's hit height flips 48px → 32px across the lg/sm rungs). data-density stamps on the root for the scope. Number unit: coefficient. |
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | CONSUMED AT THE PASSTHROUGH BOUNDARY — the native element never receives a size attribute (the §1 native collision rule); the §1 echo lands on the root and the chip/input labels stay PINNED to var(--jx-text) (the density kernel wins the visible cascade, measured). Zero size-effective readers (grep receipt). Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | SUPPLY-ONLY — zero shape-channel readers (grep receipt); the shell corner is a structural 0. Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY — the concentric broadcast for nested parts; the shell/chips keep the family hairline square (grep receipt). Number unit: px. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | CONSUMED AT THE PASSTHROUGH BOUNDARY (§1 — intercepted from rest, never a native attribute) and ZERO-READ as an axis: the field's inks are the pinned token set (shell --jx-background/--jx-border, chips --jx-muted/--jx-foreground, the flash border --jx-primary). Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | THE ROOT-PINNED ALIAS (the separator/spin/tabs shape #2, measured) — every ink (shell/chip/input/rows) rides :root alias chains (--jx-background/--jx-border/--jx-muted/--jx-foreground/--jx-primary/--jx-terminal-*), so a scoped .dark island re-derives inherited values while the field's tokens HOLD the light values (measured frozen), and root-level html.dark re-derives them (measured flip). class:dark stamps for composed descendants. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | SUPPLY-ONLY — zero elevation-carrier readers (grep receipt); the shell's well shadow is the css sheet's --shadow-well, not the axis. Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | THE FAMILY OWNS TWO MOTIONS, AXIS UNREAD — the duplicate flash (200ms primary border + the shake keyframes) and the shell's hover/focus transitions (var(--motion-150)); reduced motion kills BOTH (transitions and the shake — measured), and the suggestion panel rides the shared WAAPI kernel. Zero motion-effective readers (grep receipt). Number unit: coefficient. |
Receipts: the KEYED-EACH double weight (the chips each keys value#index — duplicate tag VALUES cannot collide; mutation census on real keys: typing + Enter ADDED a chip, a duplicate commit left the bound set unchanged with the flash class on the existing chip, Backspace on empty removed the last — mounted census matching the bound set at every step), the combobox contract (role=combobox with aria-expanded flipping on the filter, aria-activedescendant riding the roving highlight into the suggestion listbox, committed suggestions flipping their rows' aria-selected, every chip × announcing "remove (label)"), the error wiring (aria-invalid + aria-describedby → the error id, dashed shell), the comma-split paste (a,b,c → two commits), the density seat (data-density lg → sm across 48rem AND the shell hit height 48px → 32px — the one consumed axis), the theme split (the chip ink token chain: under a scoped .dark island the inherited values flip while the field's --jx-* tokens HOLD the light values; at root-level dark they re-derive — the root-pinned alias, shape #2) and the grep receipts (zero --jx-*-effective readers over ui/tags-input/) were measured on this page's served DOM (probe, task 43). LAW #19 id landscape: duplicate ids NONE page-wide. The query() seat below rides the md viewport key (48rem) on the density lane.
<script lang="ts">
import TagsInput from '@ui/tags-input.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- the md key is the registered VIEWPORT scale (48rem): below it the
small rung stamps, at 48rem+ large wins — data-density flips AND
the kernel lanes rescale the whole field (the one consumed axis) -->
<TagsInput label="stack" density={query({ md: 'large' }, 'small')} />The md key is the registered VIEWPORT scale (48rem): below it the small rung stamps (data-density="sm"); at 48rem and wider large wins ("lg" at a 1280 viewport, measured) — and because density is the consumed axis, the shell hit height rescales with it (48px vs 32px, measured). Resize across 48rem.
tags-input · universal props
<TagsInput label="px number" size={14} density="small" />
<TagsInput label="named steps" size="large" radius="medium" />a11y
Accessibility
The typing input is a combobox over a horizontal listbox of chips; the suggestion popover rides aria-activedescendant with focus never leaving the field.
Keyboard
| Key | Action |
|---|---|
| Tab | Moves focus into the typing input; leaving commits the pending text |
| Enter | Commits the highlighted suggestion or the typed text — never submits the form |
| comma | Commits a chip; a pasted "a,b,c" splits into one commit per part |
| Backspace | On an empty input, deletes the last removable chip |
| ↑ / ↓ | Roving highlight through the filtered suggestions (no wrap) |
| Esc | Closes the suggestion popover (native popover close) |
ARIA
| Attribute | Value | Description |
|---|---|---|
| role | combobox | On the typing input, with aria-expanded synced live |
| aria-activedescendant | {id}-sug-{index} | Roving highlight ID into the suggestion listbox |
| aria-autocomplete | "list" | The input is backed by the suggestion list |
| role (shell) | listbox | The chip host is a horizontal listbox; each chip is role="option" aria-selected="true" |
| aria-label | "remove {label}" | On every chip × button (type="button") |
| aria-invalid | 'true' | On the input when the error prop is provided |