jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

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

install — tags-input

$npx jixoai-ui add tags-input

https://ui.jixoai.com/r/tags-input.json

chips · press-physics removal Enter / comma / Tab / paste-split suggestion popover maxTags cap · duplicate flash $bindable Tag[]

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.

svelte node
tags-input-usage.svelte svelte
<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 typescript
bound values: svelte, typescript
node bun
at the cap the input hides · 2/3 tags
owner release
removable={false} hides the × · Backspace skips it

at least one label is required

error wiring: aria-invalid + dashed shell

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.

TagsInput usage
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

svelte
dir="rtl" — chips right-first, panel edge inline-start

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.

tags-rtl-demo.svelte svelte
<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

svelte
node bun 2/2 tags
owner release

at least one label is required

tags-types-demo.svelte svelte
<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.

TagsInput usage
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.

xs
svelte node
sm
svelte node
default
svelte node
lg
svelte node
TokenDefaultSource
--jx-hit28 / 32 / 40 / 48pxdensity
--jx-row-min28 / 32 / 40 / 48pxdensity
--jx-text11 / 12 / 13 / 15pxdensity
--jx-inset8 / 8 / 12 / 16pxdensity
--jx-gap8 / 8 / 12 / 16pxdensity

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

PropertyTypeDefaultDescription
tags bindTag[][]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 booleanfalseAllow the same value twice; false flashes the existing chip instead.
disabled booleanfalseDisables the input and every chip × (entry guards back the buttons).
variant 'solid' | 'acrylic' | 'auto''auto' · Own default, not ambientFloating-surface fill of the suggestion panel. Defaults: literal slot — own ’auto’, ambient when an axis opens.

Universal props

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity across the motion kernels (§8)

Tag

PropertyTypeDefaultDescription
value * string—The committed tag identity — duplicates compare on this.
label stringvalueDisplay text.
removable booleantrueHides 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.

PropertyTypeDefaultDescription
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.

one real query() case
<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

tags-input-universal.svelte svelte
<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

KeyAction
TabMoves focus into the typing input; leaving commits the pending text
EnterCommits the highlighted suggestion or the typed text — never submits the form
commaCommits a chip; a pasted "a,b,c" splits into one commit per part
BackspaceOn an empty input, deletes the last removable chip
↑ / ↓Roving highlight through the filtered suggestions (no wrap)
EscCloses the suggestion popover (native popover close)

ARIA

AttributeValueDescription
rolecomboboxOn 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)listboxThe 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
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h