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 display | Comma-separated label | Removable inline tags |
| Primary interaction | Click to browse | Click or type (see modes) |
| Per-tag remove affordance | No | Yes |
| Free-form entry | No | Yes (via creatable) |
| Keyboard tag navigation | N/A | Arrow keys across tags |
| Best for | Fixed 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.
| Mode | Field type | Search input location | Best for |
|---|---|---|---|
| Toggle (default) | Click target | None | Browsable enums, closed option sets |
Toggle + searchable | Click target | Inside the listbox | Enums with many options |
typeable | <input> | The input itself | Autocomplete, 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:
| Size | Min Height | Use Case |
|---|---|---|
xs | 28px | 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, 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
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 to0.debounce: milliseconds to wait after typing before theon_searchevent is pushed. Defaults to300.search_no_results_text: empty-state template;%{query}is interpolated with the typed query.search_input_placeholder: placeholder for the in-listbox search input whensearchable={true}.
Server-side search
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)}
endPair 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>Listbox Header and Footer
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 == ""))
# ...
endDisable 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>Assignee picker with avatars and server search
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}))}
endBounded 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)
endFilter bar with server search
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))}
endCreatable with a footer escape hatch
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
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 fromfieldornamewhen available, otherwise auto-generated. Defaults tonil.name(:any) - The form name. Required when not usingfield. Automatically suffixed with[]since selection is always multiple.field(Phoenix.HTML.FormField) - APhoenix.HTML.FormFieldstruct. When provided,id,name,value, anderrorsare derived automatically. Errors are only surfaced afterPhoenix.Component.used_input?/1returns true.class(:any) - Additional CSS classes applied to the floating listbox container. Defaults tonil.label(:string) - Primary label rendered above the field. Bound to the toggle (or to the input in typeable mode) for accessibility. Defaults tonil.sublabel(:string) - Muted text rendered beside the main label. Useful for optional/required hints. Defaults tonil.description(:string) - Descriptive helper text rendered between the label and the field. Defaults tonil.help_text(:string) - Help text rendered below the field. Use for long-form guidance; usesublabelordescriptionfor shorter hints. Defaults tonil.placeholder(:string) - Placeholder text shown when no tag is selected. In toggle mode, shown via a::beforepseudo-element inside the tags area. In typeable mode, shown as the native<input>placeholder when the selection is empty. Defaults tonil.typeable(:boolean) - Controls the interaction model of the field.false(default): toggle mode. Clicking the field opens a floating listbox. Pair withsearchableto 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, withcreatable, can commit free-form values.searchableis 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 tofalse.searchable(:boolean) - Whentruein toggle mode, renders a dedicated search input inside the listbox. Ignored whentypeable={true}because the field input already performs search. Defaults tofalse.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 tofalse.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 whensearchable={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>= 2for server search to avoid hammering the server on single-character queries. Defaults to0.debounce(:integer) - Debounce interval in milliseconds before theon_searchevent is pushed to the server. Ignored for client-side filtering. Defaults to300.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 theoptionsassign; a loading spinner is shown until the patch arrives. Defaults tonil.on_open(Phoenix.LiveView.JS) -Phoenix.LiveView.JScommands 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.JScommands 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 whenfieldis provided. Empty strings andnilentries are filtered out.errors(:list) - List of validation errors rendered below the field. Whenfieldis set, errors are extracted from the form field and translated automatically. Defaults to[].options(:list) (required) - A list of options. Accepts every format supported byPhoenix.HTML.Form.options_for_select/2: strings,{label, value}tuples, keyword pairs, keyword lists (withdisabled: trueon 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 withhelp_textto communicate the limit). Defaults tonil.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 to0.clearable(:boolean) - Whentrue, renders a clear (X) button that removes all selected tags at once. Hidden whenevermin > 0since clearing all would violate the floor. Defaults tofalse.include_hidden(:boolean) - Whentrue(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 tofalsewhen you need the field to be entirely absent on empty. Defaults totrue.Global attributes are accepted. Additional attributes forwarded to the hidden
<select>element (for example,formfor 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. Usein-data-highlighted:andin-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 thesizeattribute. 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.