Fluxon.Components.TagsInput (Fluxon v3.0.0)

Provides <.tags_input>, a multi-value form field that renders each selected value as a dismissible inline tag.

Selections are surfaced as removable chips inside the field instead of as a comma-separated summary like <.select multiple>. The component covers toggle and typeable interaction modes, optional in-listbox or inline search, free-form tag creation, custom tag and option templates, header and footer chrome, server-driven search, selection floors and ceilings, size variants, inner and outer affixes, a clear-all button, and Phoenix form integration. A hidden <select multiple> is the source of truth for selection state, so changesets, {:array, :string} casts, validations, and nested form params behave identically to any other multi-value form field in Fluxon.

TagsInput vs Select multiple

Both collect multiple values, but the interaction model differs:

Feature<.select multiple><.tags_input>
Selected-value displayComma-separated labelRemovable inline tags
Primary interactionClick to browseClick or type (see modes)
Per-tag remove affordanceNoYes
Free-form entryNoYes (via creatable)
Keyboard tag navigationN/AArrow keys across tags
Best forFixed enums (countries, roles)Tags, labels, assignees, chips

Reach for Fluxon.Components.Select when the option set is fixed and should be browsed as a list. Reach for <.tags_input> when each value deserves an individual remove affordance, when free-form entry is useful, or when the field doubles as an autocomplete with multi-select.

Usage

Render a tags input with a list of options:

<.tags_input
  name="departments"
  options={[{"Sales", "sales"}, {"Support", "support"}, {"Engineering", "engineering"}]}
  placeholder="Pick departments..."
/>

Options follow the same API as Phoenix.HTML.Form.options_for_select/2 and accept every format supported by <.select>:

  • Strings: ["Apple", "Banana"]
  • Tuples: [{"Label", "value"}, ...]
  • Keyword pairs: [Admin: "admin", User: "user"]
  • Atoms, integers, or ranges: [:admin, :user], 1..5
  • Disabled options via keyword form: [[key: "Archived", value: "x", disabled: true]]
  • Grouped options: [{"Team A", [{"Alpha", "a"}, ...]}, ...]
  • Nested groups: [{"Region", [{"Country", [...]}, ...]}, ...]

Bind to a Phoenix form field for automatic id/name/value/errors plumbing:

<.form :let={f} for={@form} phx-change="validate" phx-submit="save">
  <.tags_input
    field={f[:departments]}
    label="Departments"
    description="Choose one or more."
    options={@departments}
  />
</.form>

Interaction Modes

The interaction model is selected via the typeable attribute. The default is toggle mode.

Toggle mode (default)

The field acts like <.select multiple>: clicking the toggle opens a floating listbox. Selected values render as tags inside the toggle area. Best when the option set is small to medium and meant to be browsed.

From the keyboard, Space or the arrow keys open the listbox, the arrow keys and Home/End move the highlight, typing any character jumps to a matching option by label prefix, Enter or Space toggles the highlighted option, and Escape closes the listbox and returns focus to the field. Enter on a closed field is a no-op so the surrounding form can submit, mirroring a native <select>.

<.tags_input name="tags" options={@options} />

Add searchable to render a dedicated search input inside the listbox while keeping the tags in the toggle:

<.tags_input name="tags" searchable options={@options} />

Typeable mode

Set typeable to turn the field itself into an <input> with tags rendered inline before the caret. Typing filters the listbox directly; searchable has no effect because the input is the search. Best for autocomplete-style entry, large option sets, and any UI where creatable is enabled.

<.tags_input name="tags" typeable options={@options} />

While the input is focused, typing filters the listbox, the arrow keys and Home/End move the highlight, Enter commits the highlighted option (or creates a tag when creatable is set), and Escape closes the listbox.

In typeable mode tags are also keyboard-navigable as a composite widget. ArrowLeft from an empty input (at cursor position 0) focuses the last tag; from there ArrowLeft and ArrowRight move between tags, Home and End jump to the first and last tag, and Delete or Backspace removes the focused tag (moving focus to the adjacent tag, or back to the input). Backspace on an empty input also deletes the last tag and visually highlights (without focusing) the new last tag as a next-removal cue, so a second Backspace is unambiguous. All tag removal respects min.

ModeField typeSearch input locationBest for
Toggle (default)Click targetNoneBrowsable enums, closed option sets
Toggle + searchableClick targetInside the listboxEnums with many options
typeable<input>The input itselfAutocomplete, large datasets, creatable

Creatable

With both typeable and creatable, pressing Enter on a typed value that does not match any existing option commits it as a new tag whose value equals the typed text. When the typed value matches an existing (unselected) option case-insensitively, by label or value, that option is selected instead of duplicated. Duplicates against already-selected tags are silently rejected.

<.tags_input name="tags" typeable creatable options={@options} />

Created tags are submitted with the rest of the form. The server can slugify, persist, or reject the value on its own terms; the component does not coordinate creation with the server, it simply appends a new <option value={text} selected> to the hidden <select>.

Creation is client-side only

creatable does not round-trip through the server before inserting a tag. For validation (length, uniqueness, profanity, normalization), enforce it in the form's changeset or in the handler that consumes the submitted params. The tag appears immediately on the client regardless of server opinion.

Sizes

The size attribute scales the field height, padding, font size, chevron, clear button, tags, and any icons inside affix slots:

SizeMin HeightUse Case
xs28pxCompact UI, dense tables
sm32pxSecondary or supporting fields
md36pxDefault, recommended for most forms
lg40pxPrimary selections
xl44pxHero sections, prominent forms
<.tags_input size="xs" name="t1" options={@options} />
<.tags_input size="md" name="t2" options={@options} />
<.tags_input size="xl" name="t3" options={@options} />

Selection Limits

Maximum selections

max silently blocks further selection once the threshold is reached. Already-selected tags remain removable (unless min also applies):

<.tags_input name="tags" max={3} options={@options} />

In creatable mode, max also prevents committing new tags.

Minimum selections

min prevents removal below the threshold on the client. Each tag's remove button is disabled when at the floor, and the clear button is hidden whenever min > 0:

<.tags_input name="tags" min={1} clearable options={@options} />

min is a UX guard, not validation

min only prevents client-side removal. It does not validate form submissions; a malicious or bug-triggered submit can still deliver fewer values. Always enforce the selection count server-side with changeset validations (e.g. validate_length(:tags, min: 1)).

Search is client-side by default in both modes and runs case-insensitively against option labels.

<!-- Toggle mode: dedicated search input inside the listbox -->
<.tags_input name="tags" searchable options={@options} />

<!-- Typeable mode: the field itself is the search -->
<.tags_input name="tags" typeable options={@options} />

Search-related attributes:

  • search_threshold: minimum characters before filtering fires. Defaults to 0.
  • debounce: milliseconds to wait after typing before the on_search event is pushed. Defaults to 300.
  • search_no_results_text: empty-state template; %{query} is interpolated with the typed query.
  • search_input_placeholder: placeholder for the in-listbox search input when searchable={true}.

For large datasets or database-backed search, pass on_search with a LiveView event name. A loading indicator is shown while the server responds:

<.tags_input
  field={f[:tags]}
  typeable
  search_threshold={2}
  debounce={500}
  on_search="search_tags"
  options={@filtered_tags}
/>

The event payload is %{"query" => query, "id" => component_id}. Respond by updating the options assign from your LiveView handler.

Include currently-selected options in search responses

The hidden <select> is rendered by LiveView. options_for_select/2 only emits an <option selected> for values that also appear in @options, so if a search response omits a selected option, the corresponding tag disappears on the next patch and the form loses the value.

Your on_search handler must always include currently-selected options in its response. A typical pattern:

def handle_event("search_users", %{"query" => query}, socket) do
  matches = filter_users(@users, query)
  selected_ids = socket.assigns.form[:user_ids].value || []
  selected = Enum.filter(@users, &(&1.id in selected_ids))
  visible = (selected ++ matches) |> Enum.uniq_by(& &1.id)
  {:noreply, assign(socket, :filtered_users, visible)}
end

Pair on_search with field={...} and a form that has phx-change so selection changes round-trip through the server and @value persists across re-renders.

Clearable

Add a clear button that removes all selected tags at once:

<.tags_input name="tags" clearable options={@options} />

The clear button honors min: when min > 0, the clear button is hidden entirely (clearing all would violate the floor).

Custom Tag Rendering

Use the :tag slot to customize each tag's label area. The component always renders the tag wrapper (border, padding, focus ring) and the remove button; the slot only controls what appears inside the label span. The slot receives a {label, value} tuple:

<.tags_input name="assignees" value={@selected_ids} options={@assignee_options}>
  <:tag :let={{label, value}}>
    <% user = Enum.find(@users, &(&1.id == value)) %>
    <span class="inline-flex items-center gap-1.5">
      <img src={user.avatar_url} class="size-4 rounded-full" alt="" />
      {label}
    </span>
  </:tag>
</.tags_input>

Keep custom tag content lightweight for large option sets

When the :tag slot is provided, the component prepares a per-option template so a newly selected tag can render its custom markup instantly, without waiting for a server round-trip. This has negligible cost for small and medium option sets; for options numbering in the thousands, keep the slot content lightweight.

Custom Option Rendering

Use the :option slot, identical to <.select>, for fully custom listbox rows. Each option receives a {label, value} tuple. Use the in-data-highlighted: and in-data-selected: Tailwind variants to style keyboard/hover highlight and selection states:

<.tags_input name="tags" options={@tags}>
  <:option :let={{label, value}}>
    <div class={[
      "flex items-center gap-3 px-3 py-2 rounded-base",
      "in-data-highlighted:highlight",
      "in-data-selected:font-medium"
    ]}>
      <span class={["size-2 rounded-full", color_for(value)]} />
      <span>{label}</span>
    </div>
  </:option>
</.tags_input>

Affixes

Affix slots behave identically to <.select>: inner_prefix, inner_suffix, outer_prefix, and outer_suffix. Inner affixes sit inside the field border; outer affixes sit adjacent with shared rounded corners. Providing inner_suffix replaces the default chevron icon.

<.tags_input name="tags" options={@tags}>
  <:inner_prefix>
    <.icon name="hero-tag" class="icon" />
  </:inner_prefix>
  <:outer_suffix>
    <.button size="md">Apply</.button>
  </:outer_suffix>
</.tags_input>

Add content above or below the option list. Useful for filter pills, "create new" buttons, or helper text:

<.tags_input name="tags" options={@tags}>
  <:header class="p-2 border-b">
    <span class="text-xs text-foreground-soft">Suggested tags</span>
  </:header>
  <:footer class="p-2 border-t">
    <.button size="sm" class="w-full" phx-click="open_tag_modal" type="button">
      <.icon name="hero-plus" class="size-4" /> Create tag...
    </.button>
  </:footer>
</.tags_input>

Form Integration

With a form field

The recommended pattern: bind via field and let the component derive id, name, value, and errors. Because selection is always multiple, the component automatically suffixes the name with []:

<.form :let={f} for={@form} phx-change="validate" phx-submit="save">
  <.tags_input
    field={f[:tags]}
    label="Tags"
    placeholder="Add tags..."
    typeable
    creatable
    options={@tag_options}
  />
</.form>

Standalone

Use name and value directly when not driven by a form:

<.tags_input
  name="filter_tags"
  value={@active_tags}
  options={@available_tags}
/>

Handling submitted params

Values are submitted identically to <.select multiple>. A hidden <input type="hidden" name={@name}> sentinel is rendered by default so the field appears in submissions even when no tags are selected. This means empty selections arrive as [""] in params; filter the empty string out:

def handle_event("save", %{"post" => %{"tags" => tags}}, socket) do
  tags = Enum.reject(tags || [], &(&1 == ""))
  # ...
end

Disable the sentinel with include_hidden={false} when the field should be absent entirely from submissions on empty.

Disabled State

Set disabled to remove the field from the tab order, block all selection and removal, and exclude the value from form submissions (the hidden <select> is itself disabled, so the browser omits it):

<.tags_input name="tags" disabled options={@options} value={@locked_tags} />

LiveView Integration

Selection, open/closed state, the active search query, and the keyboard highlight all survive server-driven DOM patches. When the server updates the options list (for example during server-side search or when a cascading field changes the available choices), the current tags, the open listbox, and any in-progress query are preserved rather than reset.

Every selection change (add, remove, create, or clear) dispatches a native change event on the underlying multi-select, so an enclosing form's phx-change handler fires exactly as it would for a native multi-select. Bind the field with field={...} inside a <.form phx-change="..."> to round-trip each selection change through the server.

Constraining the Field Width

By default, the field fills the width of its container. To narrow only the field while keeping label, description, and help text at their natural width, apply a width utility on any ancestor that targets [data-part=field-root]:

<div class="**:data-[part=field-root]:max-w-md">
  <.tags_input label="Tags" description="..." options={@tags} />
</div>

This pattern works uniformly across all Fluxon input-shaped form components.

Examples

Blog post tagging with creation

Suggested tags from the server with free-form creation for new ones, capped at 10:

<.form :let={f} for={@post_form} phx-change="validate" phx-submit="save">
  <.tags_input
    field={f[:tags]}
    label="Tags"
    description="Pick from suggestions or press Enter to create a new tag."
    typeable
    creatable
    max={10}
    placeholder="Add tags..."
    options={@tag_suggestions}
  />

  <.button type="submit">Publish post</.button>
</.form>

Custom rendering for both tags and listbox rows, backed by a debounced LiveView search event:

<.form :let={f} for={@issue_form} phx-change="validate" phx-submit="save">
  <.tags_input
    field={f[:assignee_ids]}
    label="Assignees"
    typeable
    on_search="search_users"
    debounce={300}
    search_threshold={2}
    placeholder="Search by name..."
    options={@user_options}
  >
    <:option :let={{label, value}}>
      <div class="flex items-center gap-3 p-2 in-data-highlighted:highlight">
        <img src={avatar_url(value)} class="size-8 rounded-full" alt="" />
        <div>
          <div class="text-sm font-medium">{label}</div>
          <div class="text-xs text-foreground-softer">{user_email(value)}</div>
        </div>
      </div>
    </:option>
    <:tag :let={{label, value}}>
      <span class="inline-flex items-center gap-1">
        <img src={avatar_url(value)} class="size-4 rounded-full" alt="" />
        {label}
      </span>
    </:tag>
  </.tags_input>
</.form>
def handle_event("search_users", %{"query" => query}, socket) do
  matches = MyApp.Users.search(query)
  selected = MyApp.Users.list_by_ids(socket.assigns.issue_form[:assignee_ids].value || [])
  visible = Enum.uniq_by(selected ++ matches, & &1.id)
  {:noreply, assign(socket, :user_options, Enum.map(visible, &{&1.name, &1.id}))}
end

Bounded selection with a floor

Require at least one category and cap at five. The clear button is hidden while min is in force, and the changeset enforces the same bounds on the server:

<.form :let={f} for={@product_form} phx-change="validate" phx-submit="save">
  <.tags_input
    field={f[:categories]}
    label="Categories"
    min={1}
    max={5}
    help_text="Pick 1 to 5 categories."
    options={@categories}
  />
</.form>
def changeset(product, attrs) do
  product
  |> cast(attrs, [:categories])
  |> validate_length(:categories, min: 1, max: 5)
end

Standalone (no form) filter chips in a toolbar that drive a query:

<div class="flex items-center gap-2">
  <.tags_input
    name="active_filters"
    value={@active_filters}
    typeable
    placeholder="Filter by tag..."
    on_search="search_filter_tags"
    debounce={200}
    options={@filter_options}
  />

  <.button phx-click="reset_filters" aria-label="Reset filters">
    <.icon name="hero-x-mark" class="size-4" />
  </.button>
</div>
def handle_event("search_filter_tags", %{"query" => q}, socket) do
  {:noreply, assign(socket, :filter_options, search_tags(q))}
end

Combine creatable typing with a "Create with options..." CTA in the footer for users who need a richer creation flow than a single keystroke:

<.tags_input field={f[:labels]} typeable creatable options={@labels}>
  <:footer class="p-2 border-t">
    <.button size="sm" class="w-full" type="button" phx-click="open_label_modal">
      <.icon name="hero-plus" class="size-4" /> Create label with color...
    </.button>
  </:footer>
</.tags_input>

Compact filter in a dense table cell

An xs-sized field with the chevron replaced by a custom icon affix, no label, and a help-free layout for high-density UIs:

<.tags_input
  name="row_tags"
  size="xs"
  placeholder="Tags"
  options={@row_tag_options}
  value={@row_tags}
  phx-value-row-id={@row.id}
>
  <:inner_prefix>
    <.icon name="hero-tag" class="icon" />
  </:inner_prefix>
</.tags_input>

[INSERT LVATTRDOCS]

Summary

Components

Renders a tags input: a multi-select form field whose selected values render as dismissible inline tags with per-value remove buttons.

Components

tags_input(assigns)

Renders a tags input: a multi-select form field whose selected values render as dismissible inline tags with per-value remove buttons.

Use this component for tag-style selection (labels, assignees, categories, chips) when each value benefits from its own remove affordance, or when free-form entry is useful. The field operates in toggle mode by default and switches to an autocomplete-style typeable mode via typeable. It integrates with Phoenix forms, supports server-side search, enforces client-side selection floors and ceilings, and accepts custom templates for tags, options, listbox header and footer, and inner and outer affixes.

See the module docs for full usage, interaction modes, and keyboard behavior.

Attributes

  • id (:any) - The unique identifier for the component. Derived from field or name when available, otherwise auto-generated. Defaults to nil.

  • name (:any) - The form name. Required when not using field. Automatically suffixed with [] since selection is always multiple.

  • field (Phoenix.HTML.FormField) - A Phoenix.HTML.FormField struct. When provided, id, name, value, and errors are derived automatically. Errors are only surfaced after Phoenix.Component.used_input?/1 returns true.

  • class (:any) - Additional CSS classes applied to the floating listbox container. Defaults to nil.

  • label (:string) - Primary label rendered above the field. Bound to the toggle (or to the input in typeable mode) for accessibility. Defaults to nil.

  • sublabel (:string) - Muted text rendered beside the main label. Useful for optional/required hints. Defaults to nil.

  • description (:string) - Descriptive helper text rendered between the label and the field. Defaults to nil.

  • help_text (:string) - Help text rendered below the field. Use for long-form guidance; use sublabel or description for shorter hints. Defaults to nil.

  • placeholder (:string) - Placeholder text shown when no tag is selected. In toggle mode, shown via a ::before pseudo-element inside the tags area. In typeable mode, shown as the native <input> placeholder when the selection is empty. Defaults to nil.

  • typeable (:boolean) - Controls the interaction model of the field.

    • false (default): toggle mode. Clicking the field opens a floating listbox. Pair with searchable to render a dedicated search input inside the listbox. Best for browsable enums and small to medium option sets.
    • true: typeable mode. The field itself is a native <input> with tags rendered inline before the caret. Typing filters the listbox and, with creatable, can commit free-form values. searchable is ignored in this mode because the input is already the search. Best for autocomplete-style entry, large option sets, and creatable workflows.

    Defaults to false.

  • creatable (:boolean) - Enables free-form tag creation in typeable mode. When set, pressing Enter on a non-matching typed value appends a new <option selected> whose value equals the trimmed text. Matches against existing options (case-insensitive, by label or value) select instead of duplicating. Has no effect in toggle mode because there is no input to capture Enter. Defaults to false.

  • searchable (:boolean) - When true in toggle mode, renders a dedicated search input inside the listbox. Ignored when typeable={true} because the field input already performs search. Defaults to false.

  • disabled (:boolean) - Disables all interaction. The hidden <select> is also disabled so the value is not submitted, and the field is removed from the tab order. Defaults to false.

  • size (:string) - Controls the size of the field. All internal elements (chevron, clear button, tags, affix icons) scale proportionally.

    • xs: 28px min height. Compact UI, dense tables.
    • sm: 32px. Secondary or supporting fields.
    • md: 36px. Default, recommended for most forms.
    • lg: 40px. Primary selections.
    • xl: 44px. Hero sections or prominent forms.

    Defaults to "md". Must be one of "xs", "sm", "md", "lg", or "xl".

  • search_input_placeholder (:string) - Placeholder for the in-listbox search input when searchable={true}. Defaults to "Search...".

  • search_no_results_text (:string) - Message shown in the listbox when no options match the search query. Use %{query} as an interpolation placeholder for the typed text. Defaults to "No results found for %{query}.".

  • search_threshold (:integer) - Minimum number of characters before client filtering or server search fires. Use >= 2 for server search to avoid hammering the server on single-character queries. Defaults to 0.

  • debounce (:integer) - Debounce interval in milliseconds before the on_search event is pushed to the server. Ignored for client-side filtering. Defaults to 300.

  • on_search (:string) - LiveView event name for server-side option search. When set, client-side filtering is disabled and the event receives %{"query" => query, "id" => component_id} on each debounced keystroke. Respond by updating the options assign; a loading spinner is shown until the patch arrives. Defaults to nil.

  • on_open (Phoenix.LiveView.JS) - Phoenix.LiveView.JS commands to run after the listbox finishes opening (after the enter animation completes).

    Defaults to %Phoenix.LiveView.JS{ops: []}.

  • on_close (Phoenix.LiveView.JS) - Phoenix.LiveView.JS commands to run after the listbox finishes closing (after the leave animation completes).

    Defaults to %Phoenix.LiveView.JS{ops: []}.

  • value (:any) - The currently selected values as a list. Derived automatically when field is provided. Empty strings and nil entries are filtered out.

  • errors (:list) - List of validation errors rendered below the field. When field is set, errors are extracted from the form field and translated automatically. Defaults to [].

  • options (:list) (required) - A list of options. Accepts every format supported by Phoenix.HTML.Form.options_for_select/2: strings, {label, value} tuples, keyword pairs, keyword lists (with disabled: true on individual options), atoms, integers, ranges, grouped options, and nested groups.

  • max (:integer) - Maximum number of selections. Once reached, further selection and creation are silently blocked (no visual feedback; consider pairing with help_text to communicate the limit). Defaults to nil.

  • min (:integer) - Minimum number of selections enforced on the client. When at this count, each tag's remove button is disabled, the clear button is hidden, and programmatic removal via the JS API is refused. Not a validation substitute; enforce server-side via changesets. Defaults to 0.

  • clearable (:boolean) - When true, renders a clear (X) button that removes all selected tags at once. Hidden whenever min > 0 since clearing all would violate the floor. Defaults to false.

  • include_hidden (:boolean) - When true (default), an empty <input type="hidden" name={@name}> sentinel is rendered so the field appears in form submissions even when no tags are selected (as [""]). Set to false when you need the field to be entirely absent on empty. Defaults to true.

  • Global attributes are accepted. Additional attributes forwarded to the hidden <select> element (for example, form for form association across the DOM). Supports all globals plus: ["form"].

Slots

  • option - Custom rendering for each option in the listbox. Receives {label, value} via :let. Use in-data-highlighted: and in-data-selected: Tailwind variants to style keyboard/hover highlight and selection states. Accepts attributes:
    • class (:any) - Additional CSS classes applied to the option wrapper.
  • tag - Custom content for each selected tag's label area. Receives {label, value} via :let. The tag wrapper, remove button, and focus ring are always framework-rendered; the slot only controls what appears inside the label span. When provided, a hidden <template> is emitted per option so the JavaScript layer can reproduce the markup on dynamic selections. Accepts attributes:
    • class (:any) - Additional CSS classes applied to the tag wrapper.
  • header - Content rendered at the top of the listbox, above the option list. Useful for filter pills or helper text. Accepts attributes:
    • class (:any) - Additional CSS classes applied to the header wrapper.
  • footer - Content rendered at the bottom of the listbox, below the option list. Useful for "create new" CTAs or action buttons. Accepts attributes:
    • class (:any) - Additional CSS classes applied to the footer wrapper.
  • inner_prefix - Content rendered inside the field's left border, typically an icon. Automatically sized to match the size attribute. Accepts attributes:
    • class (:any) - Additional CSS classes applied to the inner prefix wrapper.
  • outer_prefix - Content rendered adjacent to the field on its left side, sharing rounded corners. Useful for attached labels or buttons. Accepts attributes:
    • class (:any) - Additional CSS classes applied to the outer prefix wrapper.
  • inner_suffix - Content rendered inside the field's right border. Replaces the default chevron icon when provided. Accepts attributes:
    • class (:any) - Additional CSS classes applied to the inner suffix wrapper.
  • outer_suffix - Content rendered adjacent to the field on its right side, sharing rounded corners. Useful for attached action buttons. Accepts attributes:
    • class (:any) - Additional CSS classes applied to the outer suffix wrapper.