Theme
Version
GitHub PyPI Discord
On this page

TagsInput

Use CTagsInput when a person creates an ordered list of free-form strings, such as labels, aliases, search terms, or routing keys. Committed tags and the unfinished editor draft are separate values.

Use MultiSelect when choices come from a fixed collection. Suggestions, remote filtering, and create-from-search belong to a future Combobox rather than this component. Use Tag and TagGroup to display tags without an editor or native Form value.

Add and submit tags

Press Enter or type a configured delimiter to add one tag. Each committed tag becomes one selected Option in a native multiple Select, so FormData.getAll(name) returns repeated values in tag order.

<c-CTagsInput
  name="labels"
  c-value="['urgent', 'billing']"
  c-input_attrs="{'aria-label': 'Routing labels'}"
/>

Standalone use requires a nonempty static aria-label in input_attrs. Compose the component inside CField when it needs a visible label, description, error, required marker, or shared disabled and readonly state.

Template and Python TagsInput composition
Show code
from typing import Any

import citry_ui
from citry import Component, citry
from citry_ui import CTagsInput

citry.register_library(citry_ui)


class BasicTagsInput(Component):
    class Kwargs:
        pass

    class Slots:
        pass

    def template_data(
        self,
        kwargs: Kwargs,  # noqa: ARG002
        slots: Slots,  # noqa: ARG002
    ) -> dict[str, Any]:
        return {
            "python_tags": CTagsInput(
                name="reviewers",
                value=("ada@example.test", "grace@example.test"),
                variant="filled",
                input_attrs={"aria-label": "Reviewers"},
            )
        }

    template = """
      <section
        class="tags-input-basic"
        x-data="{submitted:'Nothing submitted yet'}"
      >
        <form
          @submit.prevent="
            submitted = JSON.stringify(
              new FormData($event.target).getAll('labels')
            )
          "
        >
          <c-CField required>
            <c-fill name="label">Routing labels</c-fill>
            <c-fill name="description">
              Press Enter or comma to add a label.
            </c-fill>
            <c-fill name="default">
              <c-CTagsInput
                name="labels"
                c-value="['urgent', 'billing']"
              />
            </c-fill>
          </c-CField>
          <button type="submit">Inspect repeated values</button>
        </form>

        <article>
          <h3>Direct Python composition</h3>
          {{ python_tags }}
        </article>

        <output x-text="submitted">Nothing submitted yet</output>
      </section>
    """

    css = """
      :where(.tags-input-basic) {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
        gap: 1rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.tags-input-basic form, .tags-input-basic article) {
        display: grid;
        gap: 0.75rem;
        align-content: start;
        margin: 0;
        padding: 1rem;
        border: 1px solid color-mix(in srgb, CanvasText 20%, transparent);
        border-radius: 0.75rem;
      }

      :where(.tags-input-basic h3) {
        margin: 0;
      }

      :where(.tags-input-basic output) {
        grid-column: 1 / -1;
      }
    """


preview = BasicTagsInput()

preview  # noqa: B018

Control committed tags and the draft separately

Client value owns the ordered committed tags. Client inputValue owns the raw editor draft. Either axis can be controlled alone, both can be controlled, or both can remain uncontrolled.

onValueChange receives a complete proposed collection. A controlled request does not update tags or native Form values until the owner supplies that exact collection. An uncontrolled draft clears only after the related value request is accepted, so refusing a controlled value does not erase the person's text.

Control tags and draft ownership
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class ControlledTagsInputAxes(Component):
    template = """
      <section class="tags-input-controlled">
        <article x-data="{last:'Uncontrolled'}">
          <h3>Uncontrolled tags and draft</h3>
          <c-CTagsInput
            c-value="['alpine']"
            c-input_attrs="{'aria-label':'Uncontrolled labels'}"
            $c-props="{
              onValueChange:(next)=>last=JSON.stringify(next),
            }"
          />
          <output x-text="last">Uncontrolled</output>
        </article>

        <article x-data="{draft:'coastal',last:'Draft owned'}">
          <h3>Controlled draft</h3>
          <c-CTagsInput
            c-value="['alpine']"
            c-input_attrs="{'aria-label':'Draft-owned labels'}"
            $c-props="{
              inputValue:draft,
              onInputValueChange:(next)=>{
                draft=next;
                last=`Draft: ${next}`;
              },
            }"
          />
          <output x-text="last">Draft owned</output>
        </article>

        <article
          x-data="{
            tags:['alpine'],
            accept:false,
            last:'Value request not sent',
          }"
        >
          <h3>Controlled tags, uncontrolled draft</h3>
          <c-CTagsInput
            c-input_attrs="{'aria-label':'Value-owned labels'}"
            $c-props="{
              value:tags,
              onValueChange:(next,detail)=>{
                last=`Requested ${JSON.stringify(next)}`;
                if (accept) tags=next;
              },
            }"
          />
          <label>
            <input type="checkbox" x-model="accept" />
            Accept the next value request
          </label>
          <output x-text="last">Value request not sent</output>
        </article>

        <article
          x-data="{
            tags:['alpine'],
            draft:'harbor',
            last:'Both axes owned',
          }"
        >
          <h3>Controlled tags and draft</h3>
          <c-CTagsInput
            c-input_attrs="{'aria-label':'Fully controlled labels'}"
            $c-props="{
              value:tags,
              inputValue:draft,
              onValueChange:(next,detail)=>{
                tags=next;
                draft=detail.nextInputValue || 'owner note';
                last=`Accepted ${JSON.stringify(next)}`;
              },
              onInputValueChange:(next)=>draft=next,
            }"
          />
          <button type="button" @click="tags=['owner','ordered']">
            Replace tags from the owner
          </button>
          <output x-text="last">Both axes owned</output>
        </article>
      </section>
    """

    css = """
      :where(.tags-input-controlled) {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
        gap: 1rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.tags-input-controlled article) {
        display: grid;
        gap: 0.75rem;
        align-content: start;
        padding: 1rem;
        border: 1px solid color-mix(in srgb, CanvasText 20%, transparent);
        border-radius: 0.75rem;
      }

      :where(.tags-input-controlled h3) {
        margin: 0;
      }
    """


preview = ControlledTagsInputAxes()

preview  # noqa: B018

Passing null or removing a controlled axis releases it to its latest uncontrolled committed baseline. It does not adopt the last controlled value.

Keep paste and IME input atomic

Paste text containing a delimiter or newline to add several tags at once. The component replaces the current editor selection, validates every completed fragment, and commits the batch in order. The final unterminated fragment remains the draft.

If any fragment is empty, duplicated, invalid, or over max_tags, the whole batch is rejected. Existing tags, draft text, and selection remain unchanged. The component never partially accepts a paste.

Paste, delimiters, and composition
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class TagsInputPasteAndIme(Component):
    template = """
      <section
        class="tags-input-paste"
        x-data="{
          last:'Paste or compose in the editor',
          composing:false,
        }"
      >
        <c-CField>
          <c-fill name="label">Survey regions</c-fill>
          <c-fill name="description">
            Comma, semicolon, and a pasted newline separate regions.
            At most five tags are accepted.
          </c-fill>
          <c-fill name="default">
            <c-CTagsInput
              name="regions"
              c-value="['alpine']"
              c-delimiters="[',', ';']"
              max_tags="5"
              c-input_attrs="{
                '@compositionstart':'composing=true;last=`Composition started`',
                '@compositionend':'composing=false;last=`Composition ended`',
                '@paste':'last=`Paste received`',
              }"
              $c-props="{
                onValueChange:(next,detail)=>
                  last=`${detail.source}: ${JSON.stringify(next)}`,
                onValueInvalid:(reason,detail)=>
                  last=`Rejected ${reason}: ${detail.candidate || 'batch'}`,
              }"
            />
          </c-fill>
        </c-CField>

        <div class="tags-input-paste__sample">
          <p>Try replacing selected draft text with:</p>
          <pre>coast,forest;wetland
harbor</pre>
        </div>

        <output aria-live="polite" x-text="last">
          Paste or compose in the editor
        </output>
        <p x-show="composing">The input method editor owns Enter and delimiters.</p>
      </section>
    """

    css = """
      :where(.tags-input-paste) {
        display: grid;
        gap: 1rem;
        max-inline-size: 38rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.tags-input-paste__sample) {
        padding: 0.85rem;
        border-radius: 0.75rem;
        background: color-mix(in srgb, CanvasText 6%, Canvas);
      }

      :where(.tags-input-paste__sample p) {
        margin-block-start: 0;
      }

      :where(.tags-input-paste pre) {
        margin: 0;
        white-space: pre-wrap;
      }
    """


preview = TagsInputPasteAndIme()

preview  # noqa: B018

Enter and delimiters do not commit while an input method editor is composing. The final non-composing input is reconciled once after composition ends.

Preserve native Form behavior

The visible text editor is unnamed. The hidden native select multiple owns name, form, native required validity, and repeated values. A nonempty editable draft sets native custom validity until the person commits or clears it, so submission cannot silently omit unfinished text.

Required values, external Forms, and reset
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class TagsInputFormsAndReset(Component):
    template = """
      <section
        class="tags-input-forms"
        x-data="{
          cancelReset:false,
          result:'No Form action yet',
        }"
      >
        <form
          id="tags-input-external-form"
          @submit.prevent="
            result = JSON.stringify(
              Array.from(new FormData($event.target).entries())
            )
          "
          @reset="
            if (cancelReset) {
              $event.preventDefault();
              result='Reset canceled';
            } else {
              setTimeout(() => result='Server baselines restored', 0);
            }
          "
        >
          <h3>Specimen routing Form</h3>
          <button type="submit">Submit repeated values</button>
          <button type="reset">Reset values and draft</button>
        </form>

        <c-CTagsInput
          id="external-routing-labels"
          name="labels"
          form="tags-input-external-form"
          required
          c-value="['urgent', 'billing']"
          input_value="unfinished"
          c-input_attrs="{'aria-label':'External routing labels'}"
        />

        <label>
          <input type="checkbox" x-model="cancelReset" />
          Cancel the next reset
        </label>

        <div class="tags-input-forms__transport">
          <c-CTagsInput
            name="readonly-labels"
            form="tags-input-external-form"
            readonly
            c-value="['preserved', 'ordered']"
            c-input_attrs="{'aria-label':'Readonly labels'}"
          />
          <c-CTagsInput
            name="disabled-labels"
            form="tags-input-external-form"
            disabled
            c-value="['omitted']"
            c-input_attrs="{'aria-label':'Disabled labels'}"
          />
        </div>

        <output aria-live="polite" x-text="result">
          No Form action yet
        </output>
      </section>
    """

    css = """
      :where(.tags-input-forms) {
        display: grid;
        gap: 1rem;
        max-inline-size: 42rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.tags-input-forms form, .tags-input-forms__transport) {
        display: flex;
        flex-wrap: wrap;
        gap: 0.75rem;
        align-items: center;
      }

      :where(.tags-input-forms h3) {
        flex-basis: 100%;
        margin: 0;
      }
    """


preview = TagsInputFormsAndReset()

preview  # noqa: B018

An uncanceled reset reconstructs the server values and initial draft after the native reset action. A canceled reset changes nothing. Controlled axes receive reset requests and remain owner-supplied until accepted.

Readonly keeps the editor focusable and submits committed values through repeated hidden controls. A draft that becomes dormant while readonly remains visible but does not block submission and is not submitted. Disabled state submits no entries.

Without JavaScript, the native multiple Select is visible. It supports deselecting server values, required validity, repeated submission, external Form ownership, and reset, but it cannot create new free-form values.

Let Field own shared state

Inside CField, configure required, disabled, readonly, and invalid on the Field. The TagsInput registers its editor as the one visible control while the native Select retains Form validity.

Field-owned TagsInput states
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class TagsInputFieldStates(Component):
    template = """
      <section
        class="tags-input-fields"
        x-data="{
          required:true,
          readonly:false,
          moveDisabled:true,
        }"
      >
        <div class="tags-input-fields__controls">
          <label><input type="checkbox" x-model="required" /> Required</label>
          <label><input type="checkbox" x-model="readonly" /> Readonly</label>
          <button
            type="button"
            @click="
              const target = moveDisabled ? $refs.disabled : $refs.enabled;
              target.append($refs.moving);
              moveDisabled = !moveDisabled;
            "
          >
            Move the Field between fieldsets
          </button>
        </div>

        <c-CField
          $c-props="{required,readonly}"
        >
          <c-fill name="label">Publication topics</c-fill>
          <c-fill name="description">
            Field owns required and readonly state for the TagsInput.
          </c-fill>
          <c-fill name="default">
            <c-CTagsInput
              name="topics"
              c-value="['botany', 'fieldwork']"
            />
          </c-fill>
        </c-CField>

        <c-CField invalid>
          <c-fill name="label">Review labels</c-fill>
          <c-fill name="default">
            <c-CTagsInput name="review" c-value="['needs-source']" />
          </c-fill>
          <c-fill name="error">Resolve the review label before publishing.</c-fill>
        </c-CField>

        <div class="tags-input-fields__fieldsets">
          <fieldset x-ref="enabled">
            <legend>Enabled ancestry</legend>
            <div x-ref="moving">
              <c-CField>
                <c-fill name="label">Moved labels</c-fill>
                <c-fill name="default">
                  <c-CTagsInput name="moved" c-value="['portable']" />
                </c-fill>
              </c-CField>
            </div>
          </fieldset>
          <fieldset x-ref="disabled" disabled>
            <legend>Disabled ancestry</legend>
          </fieldset>
        </div>
      </section>
    """

    css = """
      :where(.tags-input-fields) {
        display: grid;
        gap: 1rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.tags-input-fields__controls, .tags-input-fields__fieldsets) {
        display: flex;
        flex-wrap: wrap;
        gap: 0.75rem;
      }

      :where(.tags-input-fields fieldset) {
        flex: 1 1 16rem;
        min-inline-size: 0;
      }
    """


preview = TagsInputFieldStates()

preview  # noqa: B018

The visible editor mirrors effective requiredness with aria-required. Native invalid focus moves to the editor when possible, then to a safe Dialog or document fallback if the editor is unavailable.

The editor is the sole sequential Tab stop. At the start of an empty draft, Backspace first highlights the last tag and a second Backspace removes it. Logical arrow movement visits tags while DOM focus remains in the editor. Delete removes the highlighted tag, Home and End jump to an edge, and Escape returns to ordinary editing.

Keyboard, focus, and removal
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class TagsInputKeyboardAndFocus(Component):
    template = """
      <section
        class="tags-input-keyboard"
        x-data="{last:'Focus an editor to begin'}"
      >
        <article>
          <h3>Left-to-right navigation</h3>
          <p>
            At an empty start position, Backspace selects the last tag.
            Press it again to remove. Arrow keys, Home, End, Delete, and Escape
            operate while focus stays in the editor.
          </p>
          <c-CTagsInput
            c-value="['alpine', 'forest', 'harbor']"
            c-input_attrs="{
              'aria-label':'Keyboard labels',
              '@focus':'last=`LTR editor focused`',
            }"
            $c-props="{
              onValueChange:(next,detail)=>
                last=`${detail.source}: ${JSON.stringify(next)}`,
            }"
          />
        </article>

        <article dir="rtl">
          <h3>Right-to-left navigation</h3>
          <p>Physical arrows follow the visual row while value order stays stable.</p>
          <c-CTagsInput
            c-value="['جبال', 'غابة', 'ميناء']"
            c-input_attrs="{
              'aria-label':'وسوم لوحة المفاتيح',
              '@focus':'last=`RTL editor focused`',
            }"
          />
        </article>

        <output aria-live="polite" x-text="last">
          Focus an editor to begin
        </output>
      </section>
    """

    css = """
      :where(.tags-input-keyboard) {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(min(100%, 19rem), 1fr));
        gap: 1rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.tags-input-keyboard article) {
        display: grid;
        gap: 0.75rem;
        align-content: start;
        padding: 1rem;
        border: 1px solid color-mix(in srgb, CanvasText 20%, transparent);
        border-radius: 0.75rem;
      }

      :where(.tags-input-keyboard h3, .tags-input-keyboard p) {
        margin: 0;
      }

      :where(.tags-input-keyboard output) {
        grid-column: 1 / -1;
      }
    """


preview = TagsInputKeyboardAndFocus()

preview  # noqa: B018

Remove controls are native Buttons named from the tag value. A persistent polite status announces accepted additions and removals, highlighted tags, and rejected transactions. TagsInput does not use listbox, grid, combobox, or toolbar roles.

Choose a variant and size

outline, filled, and plain variants combine with sm, md, and lg sizes. Long values wrap inside the control. max_tags blocks only later additions when the current collection is already at or above the maximum.

Variants, sizes, and boundary states
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class TagsInputVariantsAndSizes(Component):
    def template_data(self, kwargs, slots) -> dict[str, object]:  # noqa: ANN001, ARG002
        return {
            "variants": ("outline", "filled", "plain"),
            "sizes": ("sm", "md", "lg"),
        }

    template = """
      <section class="tags-input-variants">
        <div class="tags-input-variants__grid">
          <c-for each="variant in variants">
            <c-for each="size in sizes">
              <article>
                <code>{{ variant }} / {{ size }}</code>
                <c-CTagsInput
                  #c-key="f'{variant}-{size}'"
                  c-variant="variant"
                  c-size="size"
                  c-value="['alpine', 'coastal']"
                  c-input_attrs="{
                    'aria-label':f'{variant} {size} labels',
                  }"
                />
              </article>
            </c-for>
          </c-for>
        </div>

        <div class="tags-input-variants__boundaries">
          <article>
            <h3>Empty and required</h3>
            <c-CTagsInput
              required
              placeholder="Add a required label"
              c-input_attrs="{'aria-label':'Required empty labels'}"
            />
          </article>
          <article>
            <h3>At maximum</h3>
            <c-CTagsInput
              max_tags="2"
              c-value="['one', 'two']"
              c-input_attrs="{'aria-label':'Maximum labels'}"
            />
          </article>
          <article style="color-scheme:dark">
            <h3>Dark and narrow</h3>
            <c-CTagsInput
              invalid
              c-value="[
                'a-very-long-unbroken-routing-label-that-stays-contained',
              ]"
              c-input_attrs="{'aria-label':'Long invalid labels'}"
            />
          </article>
        </div>
      </section>
    """

    css = """
      :where(.tags-input-variants) {
        display: grid;
        gap: 1.25rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.tags-input-variants__grid) {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
        gap: 0.75rem;
      }

      :where(.tags-input-variants article) {
        display: grid;
        gap: 0.5rem;
        min-inline-size: 0;
      }

      :where(.tags-input-variants__boundaries) {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
        gap: 0.75rem;
      }

      :where(.tags-input-variants__boundaries article) {
        inline-size: min(100%, 20rem);
        padding: 0.85rem;
        border-radius: 0.75rem;
        background: Canvas;
        color: CanvasText;
      }

      :where(.tags-input-variants h3) {
        margin: 0;
      }
    """


preview = TagsInputVariantsAndSizes()

preview  # noqa: B018

Customize stable parts and variables

Public --cui-tags-input-* variables tune color, spacing, sizing, and tag presentation. Stable part selectors target the root, control, tag list, tags, labels, remove Buttons, editor, and status node.

Brand and environment customization
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class TagsInputCustomization(Component):
    template = """
      <section class="tags-input-customization">
        <article class="tags-input-brand tags-input-brand--orchard">
          <h3>Orchard field notes</h3>
          <c-CTagsInput
            class_="brand-tags"
            c-value="['pear', 'pollinator']"
            c-input_attrs="{'aria-label':'Orchard labels'}"
          />
        </article>

        <article
          class="tags-input-brand tags-input-brand--harbor"
          style="color-scheme:dark"
        >
          <h3>Harbor field notes</h3>
          <c-CTagsInput
            class_="brand-tags"
            variant="filled"
            c-value="['tide', 'harbor']"
            c-input_attrs="{'aria-label':'Harbor labels'}"
          />
        </article>
      </section>
    """

    css = """
      :where(.tags-input-customization) {
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
        gap: 1rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.tags-input-brand) {
        display: grid;
        gap: 0.75rem;
        align-content: start;
        min-block-size: 12rem;
        padding: 1rem;
        border-radius: 1rem;
      }

      :where(.tags-input-brand h3) {
        margin: 0;
      }

      :where(.tags-input-brand--orchard) {
        background: #f5f0df;
        color: #203422;
        --cui-tags-input-background: #fffdf5;
        --cui-tags-input-border-color: #78916d;
        --cui-tags-input-focus-color: #315f37;
        --cui-tags-input-tag-background: #d9e9cf;
        --cui-tags-input-tag-border-color: #78916d;
      }

      :where(.tags-input-brand--harbor) {
        background: #102b38;
        color: #eefaff;
        --cui-tags-input-background: #173c4c;
        --cui-tags-input-foreground: #eefaff;
        --cui-tags-input-border-color: #72b5ce;
        --cui-tags-input-focus-color: #c6ecff;
        --cui-tags-input-tag-background: #29586b;
        --cui-tags-input-tag-foreground: #eefaff;
      }

      .tags-input-brand .brand-tags
      [data-citry-ui-part="remove"] {
        border-radius: 999px;
        outline-offset: 2px;
      }

      @media (forced-colors: active) {
        :where(.tags-input-brand) {
          border: 1px solid CanvasText;
        }
      }

      @media print {
        :where(.tags-input-brand) {
          min-block-size: auto;
          background: transparent;
          color: black;
        }
      }
    """


preview = TagsInputCustomization()

preview  # noqa: B018

Unlayered application rules override the Citry UI theme layer whether loaded before or after the component stylesheet. A named application layer must be ordered after citry-ui.theme.

Preserve state through server updates

Correlated server morphs preserve uncontrolled committed tags, draft, selection, focus, and highlighted-tag identity when their server baselines are unchanged. A changed baseline replaces only the matching uncontrolled axis.

Morph preservation and cleanup
Show code
from __future__ import annotations

import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class TagsInputMorphAndCleanup(Component):
    class Kwargs:
        step: int = 0

    class Slots:
        pass

    class Events:
        def refresh(self) -> TagsInputMorphAndCleanup:
            return TagsInputMorphAndCleanup()

        def advance(self) -> TagsInputMorphAndCleanup:
            return TagsInputMorphAndCleanup(step=2)

    def template_data(self, kwargs: Kwargs, slots: Slots) -> dict[str, object]:  # noqa: ARG002
        baseline = ("server-one", "server-two")
        if kwargs.step >= 2:
            baseline = ("new-server-baseline",)
        return {"baseline": baseline, "step": kwargs.step}

    template = """
      <section
        class="tags-input-morph"
        x-data="{
          controlled:false,
          tags:['owner-one'],
          mounted:true,
        }"
      >
        <div class="tags-input-morph__controls">
          <button type="button" @c-click="refresh">
            Morph with the same baseline
          </button>
          <button type="button" @c-click="advance">
            Morph to a new baseline
          </button>
          <button type="button" @click="controlled=!controlled">
            Toggle controlled handoff
          </button>
          <button type="button" @click="mounted=!mounted">
            Remove or restore the fixture
          </button>
        </div>

        <p>Server step: <output>{{ step }}</output></p>

        <template x-if="mounted">
          <div>
            <c-CTagsInput
              #c-key="'tags-input-morph-target'"
              id="tags-input-morph-target"
              c-value="baseline"
              input_value="unfinished"
              c-input_attrs="{'aria-label':'Morph labels'}"
              $c-props="{
                value:controlled ? tags : null,
                onValueChange:(next)=>{
                  if (controlled) tags=next;
                },
              }"
            />
          </div>
        </template>

        <p>
          Unchanged server baselines preserve uncontrolled tags, draft,
          selection, and focus. Step two supplies a new baseline. An active
          composition keeps the exact editor node through either morph.
        </p>
      </section>
    """

    css = """
      :where(.tags-input-morph) {
        display: grid;
        gap: 1rem;
        max-inline-size: 40rem;
        color: CanvasText;
        font-family: ui-sans-serif, system-ui, sans-serif;
      }

      :where(.tags-input-morph__controls) {
        display: flex;
        flex-wrap: wrap;
        gap: 0.75rem;
      }

      :where(.tags-input-morph p) {
        margin: 0;
      }
    """


preview = TagsInputMorphAndCleanup()

preview  # noqa: B018

An active composition keeps the exact editor DOM node. Removing the component cancels pending reset, focus, status, and controlled-acceptance work.

Distinguish callbacks from native events

Use these semantic component callbacks through $c-props:

  • onValueChange for a valid add, removal, or controlled reset request;
  • onInputValueChange for draft edits and accepted draft transitions; and
  • onValueInvalid for a rejected empty, duplicate, maximum, delimiter, or invalid-value transaction.

Native editor events remain ordinary Alpine listeners such as @input, @paste, @focus, and @blur in input_attrs. Native bubbling input and change events on the Select proxy report accepted uncontrolled value changes. Controlled value requests dispatch no native proxy change event.

TagsInput dispatches no custom DOM event and exposes no public method. Use an ordinary ref when application code needs to focus or inspect the editor.

Treat attributes and values as data

attrs targets the root and input_attrs targets the editor. They accept ordinary nonconflicting attributes, styling, permitted accessibility hints, and Alpine @event or x-on:event observers. The component rejects values that can replace its identity, native Form ownership, state, Field relationships, structure, or Alpine lifecycle.

Tag values, drafts, placeholders, and message substitutions are assigned as text or native values. They are never evaluated as HTML, URLs, selectors, or Alpine expressions.

API reference

Inputs

CTagsInput server inputs

Server inputs are passed in a template through <c-CTagsInput ... /> or in Python through CTagsInput(...).

InputTypeDefaultEffect
namestr | NoneNoneSets the repeated native Form field name; omission makes values nonparticipating.
formstr | NoneNoneAssociates the native proxy and readonly transports with a Form ID.
idstr | NonegeneratedSets the public control ID exchanged between the native fallback and initialized editor.
valueSequence[str]()Sets the initial ordered canonical unique tags and repeated Form values.
input_valuestr""Sets the initial raw unfinished editor draft.
requiredbool | NoneNoneEnables native required validity outside Field.
disabledbool | NoneNoneDisables interaction and removes all successful controls outside Field.
readonlybool | NoneNoneBlocks editing while repeated hidden controls preserve submission outside Field.
invalidbool | NoneNoneAdds owner-supplied invalid presentation outside Field.
placeholderstr | NoneNoneSets editor placeholder text.
delimitersSequence[str](",",)Sets unique server-only single-code-point token separators.
max_tagsint | NoneNoneLimits later additions to a positive maximum without removing existing tags.
autocompletestr | NoneNoneSets the editor autocomplete hint.
inputmodestr | NoneNoneSets the editor virtual-keyboard hint.
variant"outline" | "filled" | "plain" (CTagsInputVariant)"outline"Selects the control treatment.
size"sm" | "md" | "lg" (CTagsInputSize)"md"Selects editor, tag, and control geometry.
messagesCTagsInputMessages | None (CTagsInputMessages)NoneOverrides catalog-backed removal, status, rejection, and unfinished-draft text per field.
class_CClassValue | None (CClassValue)NoneAdds root classes and merges them with attrs.
styleCStyleValue | None (CStyleValue)NoneAdds root inline styles and merges them with attrs.
attrsMapping[str, object] | NoneNoneAdds copied allowed attributes to the root.
input_attrsMapping[str, object] | NoneNoneAdds copied allowed naming, descriptive, hint, style, and native-listener attributes to the editor.

CTagsInput client inputs

Client inputs are passed in the browser through the $c-props="{ ... }" attribute on <c-CTagsInput />.

InputTypeOmitted behaviorEffect
valuestring[] | nullReleases to the latest committed uncontrolled baseline; null has the same effect.Controls ordered committed tags while supplied as a valid array.
inputValuestring | nullReleases to the latest committed uncontrolled draft baseline; null has the same effect.Controls the raw editor draft while supplied as a valid string.
placeholderstring | nullUses the server value.Controls editor placeholder text; null releases and an empty string removes the attribute.
autocompletestring | nullUses the server value.Controls the editor autocomplete hint; null releases and an empty string removes the attribute.
inputmodestring | nullUses the server value.Controls the editor inputmode hint; null releases and an empty string removes the attribute.
requiredbooleanUses the server or Field fallback.Controls native required validity and the editor accessibility mirror outside Field.
disabledbooleanUses the server or Field fallback.Controls interaction and Form participation outside Field.
readonlybooleanUses the server or Field fallback.Controls read-only interaction and repeated hidden transport outside Field.
invalidbooleanUses the server or Field fallback.Controls owner-supplied invalid presentation outside Field.
maxTagspositive integer | nullUses the server value.Controls the addition limit; null removes the maximum.
variant"outline" | "filled" | "plain" (CTagsInputVariant)Uses the server value.Controls presentation treatment.
size"sm" | "md" | "lg" (CTagsInputSize)Uses the server value.Controls editor, tag, and control geometry.
onValueChangefunctionOmission or null selects no value callback.Receives valid add, removal, and controlled reset requests.
onInputValueChangefunctionOmission or null selects no draft callback.Receives direct draft edits and acceptance-gated draft transitions.
onValueInvalidfunctionOmission or null selects no rejection callback.Receives one structured notice for each rejected user transaction.

Slots

-

Events

Component events are callback inputs supplied through $c-props. Native browser events remain available through Alpine @... attributes.

CTagsInput events

EventSignatureTrigger and timingDetailControlled and cancellation behavior
onValueChange(nextValue: string[], detail: CTagsInputValueChangeDetail) => void (CTagsInputValueChangeDetail)A valid enabled add or removal is requested, or a controlled value axis receives an uncanceled reset request.{source, added, removed, candidates, previousValue, nextInputValue, controlled} (CTagsInputValueChangeDetail)Runs after full batch validation. Uncontrolled values commit first; controlled values remain unchanged until an exact later acceptance edge.
onInputValueChange(nextDraft: string, detail: CTagsInputInputValueChangeDetail) => void (CTagsInputInputValueChangeDetail)A direct editor input or accepted commit changes the draft, or a controlled draft receives an uncanceled reset request.{source, previousValue, nextValue, controlled, composing} (CTagsInputInputValueChangeDetail)Direct input is synchronous. Commit-related clear or trailing draft waits for the related value acceptance and matching draft generation.
onValueInvalid(reason: CTagsInputInvalidReason, detail: CTagsInputInvalidDetail) => void (CTagsInputInvalidReason, CTagsInputInvalidDetail)An enabled editable Enter, delimiter, or paste transaction fails an empty, duplicate, maximum, delimiter, or invalid-value guard.{source, candidate, candidates, value, inputValue, maxTags, controlled} (CTagsInputInvalidDetail)Fires once for the atomic transaction without changing tags, proxy values, draft, or selection.

Methods

-

CSS

CSS variables to theme the components. Set them on an ancestor or the component itself.

CTagsInput CSS variables

Apply these variables to CTagsInput or one of its ancestors.

VariableTypePurposeDefault
--cui-tags-input-backgroundcolorControl background.Canvas
--cui-tags-input-foregroundcolorEditor and tag text.CanvasText
--cui-tags-input-border-colorcolorResting control border.color-mix(in srgb, CanvasText 28%, transparent)
--cui-tags-input-hover-border-colorcolorEnabled hover border.color-mix(in srgb, CanvasText 55%, transparent)
--cui-tags-input-focus-colorcolorFocus-visible outline.Highlight
--cui-tags-input-invalid-border-colorcolorRevealed or owner-supplied invalid border.light-dark(#b42318, #fda29b)
--cui-tags-input-disabled-backgroundcolorDisabled control background.color-mix(in srgb, CanvasText 6%, Canvas)
--cui-tags-input-tag-backgroundcolorTag background.color-mix(in srgb, CanvasText 8%, Canvas)
--cui-tags-input-tag-foregroundcolorTag text and removal foreground.CanvasText
--cui-tags-input-tag-border-colorcolorTag boundary.color-mix(in srgb, CanvasText 18%, transparent)
--cui-tags-input-tag-highlighted-backgroundcolorKeyboard-active tag background.light-dark(#dbeafe, #19376d)
--cui-tags-input-tag-highlighted-border-colorcolorKeyboard-active tag border.Highlight
--cui-tags-input-radiuslengthControl and tag rounding.0.5rem
--cui-tags-input-min-heightlengthMinimum control height.2.5rem
--cui-tags-input-paddinglengthControl internal inset.0.375rem 0.5rem
--cui-tags-input-gaplengthSpace between tags and editor.0.375rem
--cui-tags-input-tag-gaplengthSpace between each tag label and remove Button.0.25rem
--cui-tags-input-font-sizelengthEditor and tag text size.1rem

Attributes

HTML attributes defined on the components that you can refer to for CSS, inspection, and testing. Read-only.

CTagsInput attributes

AttributeElementTypeMeaning
data-emptyRoot divpresent | absentPresent when no effective tags exist.
data-requiredRoot divpresent | absentMirrors effective requiredness.
data-disabledRoot divpresent | absentMirrors effective disabledness.
data-readonlyRoot divpresent | absentMirrors effective readonly state.
data-invalidRoot divpresent | absentMirrors owner invalidity or a revealed native-invalid episode.
data-focusedRoot divpresent | absentPresent while the editor has focus-visible context.
data-at-maxRoot divpresent | absentPresent when the effective count is at or above maxTags.
data-variantRoot div"outline" | "filled" | "plain" (CTagsInputVariant)Mirrors effective treatment.
data-sizeRoot div"sm" | "md" | "lg" (CTagsInputSize)Mirrors effective geometry.

CTagsInput attributes

AttributeElementTypeMeaning
idNative multiple Selectpublic ID or derived native IDOwns the public ID in fallback mode and the derived ID after initialization.
multipleNative multiple SelectpresentProduces one repeated Form entry per selected Option.
nameNative multiple Selectstring | absentSupplies the repeated Form field name while editable.
formNative multiple SelectForm ID | absentAssociates an external Form owner.
requiredNative multiple Selectpresent | absentOwns native required validity.
disabledNative multiple Selectpresent | absentBars validation and submission for readonly or disabled transport modes.
aria-hiddenNative multiple Select"true" | absentHides the proxy from accessibility APIs only after successful initialization.
tabindexNative multiple Select"-1" | absentRemoves the initialized proxy from sequential focus.
aria-invalidNative multiple Select"true" | absentMirrors effective visible invalidity.

CTagsInput attributes

AttributeElementTypeMeaning
idEditor inputpublic ID or derived editor IDOwns the public ID after initialization and the derived ID in fallback mode.
typeEditor input"text"Provides ordinary text editing and IME behavior.
readonlyEditor inputpresent | absentBlocks edits while retaining focusability.
disabledEditor inputpresent | absentRemoves editor interaction and focus.
aria-labelEditor and proxynon-whitespace string | absentSupplies the required standalone static accessible name.
aria-labelledbyEditor and proxyField label IDREF | absentMirrors Field-owned generated naming.
aria-describedbyEditor and proxydescription and error IDREFs | absentMirrors Field or allowed standalone descriptions.
aria-requiredEditor input"true" | absentMirrors native proxy requiredness on the visible control.
aria-invalidEditor input"true" | absentMirrors owner invalidity or a revealed native-invalid episode.

CTagsInput attributes

AttributeElementTypeMeaning
data-highlightedTag spanpresent | absentMarks the visually active tag while DOM focus remains in the editor.
typeRemove Button"button"Prevents accidental Form submission.
tabindexRemove Button"-1"Keeps the editor as the sole sequential Tab stop.
aria-labelRemove Buttonlocalized stringNames removal with the exact tag value.
disabledRemove Buttonpresent | absentBlocks removal while readonly or disabled.

CTagsInput attributes

AttributeElementTypeMeaning
roleStatus span"status"Exposes nonurgent accepted, rejected, and navigation updates.
aria-liveStatus span"polite"Queues updates without interrupting current speech.
aria-atomicStatus span"true"Announces each complete status sentence.

Selectors

Selectors for the DOM nodes in the components that you can use for CSS, inspection, and testing.

CTagsInput selectors

SelectorElementPurpose
[data-citry-ui-part="tags-input"]Root divState reflections and class, style, and attrs destination.
[data-citry-ui-part="control"]Visible control divWraps committed tags and the editor.
[data-citry-ui-part="tag-list"]Tag-list spanWraps zero or more component-owned tag visuals before the editor.
[data-citry-ui-part="tag"]Tag spanDisplays one effective canonical value and highlighted state.
[data-citry-ui-part="tag-label"]Tag label spanDisplays the exact effective string.
[data-citry-ui-part="remove"]Native ButtonRemoves its named tag by pointer, touch, or programmatic activation.
[data-citry-ui-part="input"]Native text inputSole custom editor and initialized focus owner.
[data-citry-ui-part="status"]Visually hidden spanPersistent polite accepted, rejected, and navigation announcements.

Interfaces

Aliases and data shapes referenced above.

Input type aliases

InterfaceDefinition
CClassValuestr | Mapping[str, bool] | Sequence[CClassValue]
CStyleValuestr | Mapping[str, object] | Sequence[CStyleValue]
CTagsInputVariantLiteral["outline", "filled", "plain"]
CTagsInputSizeLiteral["sm", "md", "lg"]
CTagsInputChangeSourceLiteral["input", "enter", "delimiter", "paste", "backspace", "delete", "remove", "reset"]
CTagsInputInvalidReasonLiteral["empty", "duplicate", "maximum", "delimiter", "invalid-value"]

CTagsInputMessages

FieldTypeDefaultMeaning
remove_labelstr | NoneNoneOverrides the catalog-backed remove label and requires {value}.
added_messagestr | NoneNoneOverrides the accepted-addition announcement and requires {value}.
removed_messagestr | NoneNoneOverrides the accepted-removal announcement and requires {value}.
selected_messagestr | NoneNoneOverrides the active-tag announcement and requires {value}.
duplicate_messagestr | NoneNoneOverrides duplicate rejection and requires {value}.
maximum_messagestr | NoneNoneOverrides maximum rejection and requires {max}.
empty_messagestr | NoneNoneOverrides the empty-candidate announcement.
invalid_messagestr | NoneNoneOverrides the noncanonical-candidate announcement.
uncommitted_messagestr | NoneNoneOverrides native custom validity for an editable unfinished draft.

CTagsInputValueChangeDetail

FieldTypeDefaultMeaning
sourceCTagsInputChangeSource-Identifies the interaction or reset request.
addedstring[]-Contains accepted or requested additions in order.
removedstring[]-Contains accepted or requested removals in order.
candidatesstring[]-Contains the complete atomic candidate batch.
previousValuestring[]-Copies the effective collection before the request.
nextInputValuestring-Supplies the draft requested only after exact value acceptance.
controlledboolean-Reports whether client value owns the collection.

CTagsInputInputValueChangeDetail

FieldTypeDefaultMeaning
sourceCTagsInputChangeSource-Identifies direct input, accepted tokenization, or reset.
previousValuestring-Copies the effective draft before the request.
nextValuestring-Copies the requested next draft.
controlledboolean-Reports whether client inputValue owns the draft.
composingboolean-Reports whether an input callback occurred during active composition.

CTagsInputInvalidDetail

FieldTypeDefaultMeaning
sourceCTagsInputChangeSource-Identifies Enter, delimiter, or paste as the rejected source.
candidatestring | null-Identifies the first offending candidate when one exists.
candidatesstring[]-Copies the complete attempted atomic batch.
valuestring[]-Copies the unchanged effective tags.
inputValuestring-Copies the unchanged effective draft.
maxTagsnumber | null-Reports the effective maximum.
controlledboolean-Reports whether client value owns the collection.

Translation keys

Catalog keys used by this family. An explicit component input or slot listed in Override takes precedence over the catalog for that instance.

CTagsInput translation keys

KeyPurposeVariablesOverrideBrowser updates
citry-ui-tags-input-removeNames each tag remove control.value: strmessages.remove_label$c-tr handles initial controls; i18n.bind() handles recreated controls.
citry-ui-tags-input-addedAnnounces accepted additions.value: strmessages.added_messageOne-shot i18n.tr() when the interaction occurs.
citry-ui-tags-input-removedAnnounces accepted removals.value: strmessages.removed_messageOne-shot i18n.tr() when the interaction occurs.
citry-ui-tags-input-selectedAnnounces keyboard-active tags.value: strmessages.selected_messageOne-shot i18n.tr() when the interaction occurs.
citry-ui-tags-input-duplicateAnnounces duplicate rejection.value: strmessages.duplicate_messageOne-shot i18n.tr() when the interaction occurs.
citry-ui-tags-input-maximumAnnounces the maximum-tag limit.max: strmessages.maximum_messageOne-shot i18n.tr() with locale-formatted max.
citry-ui-tags-input-requiredAnnounces an empty candidate.Nonemessages.empty_messageOne-shot i18n.tr() when the interaction occurs.
citry-ui-tags-input-invalidAnnounces a noncanonical candidate.Nonemessages.invalid_messageOne-shot i18n.tr() when the interaction occurs.
citry-ui-tags-input-unfinishedSupplies native validity text for an unfinished draft.Nonemessages.uncommitted_messageOne-shot i18n.tr() when validity is evaluated.