Tags Input

Multi-value field that surfaces selections as removable inline tags, with toggle, typeable, and creatable modes.

Basic

.heex
<div class="w-[320px]">
  <.tags_input
    name="basic_tags"
    placeholder="Pick a few topics..."
    options={[
      {"Phoenix", "phoenix"},
      {"LiveView", "liveview"},
      {"Elixir", "elixir"},
      {"Ecto", "ecto"},
      {"OTP", "otp"},
      {"Tailwind", "tailwind"}
    ]}
  />
</div>

Typeable

Set typeable to make the field itself an input. Tags render before the caret; typing filters the listbox; ArrowLeft from an empty input focuses the last tag.

.heex
<.tags_input
  name="topics"
  typeable
  placeholder="Search topics..."
  options={@topics}
/>

Toggle with search

Pair the default toggle mode with searchable to render a sticky search input inside the listbox while keeping tags inside the toggle.

.heex
<.tags_input
  name="languages"
  searchable
  placeholder="Pick languages..."
  options={@languages}
/>

Sizes

.heex
<.tags_input size="xs" name="tags_xs" options={@tags} />
<.tags_input size="sm" name="tags_sm" options={@tags} />
<.tags_input size="md" name="tags_md" options={@tags} />
<.tags_input size="lg" name="tags_lg" options={@tags} />
<.tags_input size="xl" name="tags_xl" options={@tags} />

Label, description, and help text

Combine label, sublabel, description, and help_text for the full form-field chrome.

Optional

Used to surface this post in topic feeds.

You can edit categories anytime from the post settings.
.heex
<.tags_input
  name="categories"
  label="Categories"
  sublabel="Optional"
  description="Used to surface this post in topic feeds."
  help_text="You can edit categories anytime from the post settings."
  placeholder="Add categories..."
  options={@categories}
/>

Pre-selected values

Pass a list to value to seed the initial selection. Each matching option renders as a tag on first paint.

.heex
<.tags_input
  name="frameworks"
  label="Favorite frameworks"
  value={["phoenix", "liveview", "tailwind"]}
  options={@frameworks}
/>

Option formats

Options accept any shape Phoenix.HTML.Form.options_for_select/2 understands: bare strings, keyword pairs, ranges, and more.

.heex
<.tags_input name="strings" label="Bare strings" options={["Love", "Justice", "Sacrifice"]} />

<.tags_input
  name="languages"
  label="Keyword pairs"
  options={[english: "en", spanish: "es", french: "fr"]}
/>

<.tags_input name="months" label="Numeric range" options={1..6} />

Disabled options

Mark individual options with disabled: true. They are visually muted, ignored on click, and skipped during keyboard navigation.

.heex
<div class="w-[320px]">
  <.tags_input
    name="frameworks"
    label="Frontend frameworks"
    placeholder="Pick a few..."
    searchable
    options={[
      [key: "React", value: "react"],
      [key: "Vue", value: "vue"],
      [key: "Angular", value: "angular", disabled: true],
      [key: "Svelte", value: "svelte"],
      [key: "Ember", value: "ember", disabled: true],
      [key: "Solid", value: "solid"]
    ]}
  />
</div>

Grouped and nested options

Wrap options in {group_label, children} tuples. Groups can nest to any depth and each level receives a --depth CSS variable for indentation.

.heex
<.tags_input
  name="cities"
  label="Cities"
  placeholder="Add cities..."
  options={[
    {"Americas",
     [
       {"United States", [{"New York", "nyc"}, {"San Francisco", "sf"}]},
       {"Canada", [{"Toronto", "toronto"}, {"Vancouver", "vancouver"}]}
     ]},
    {"Europe",
     [
       {"France", [{"Paris", "paris"}, {"Lyon", "lyon"}]},
       {"Germany", [{"Berlin", "berlin"}, {"Munich", "munich"}]}
     ]}
  ]}
/>

Clearable

Pass clearable to render a clear button that removes every selected tag at once. Hidden whenever min > 0.

.heex
<.tags_input
  name="tags"
  label="Tags"
  clearable
  value={["phoenix", "liveview", "elixir"]}
  options={@tags}
/>

Inner affixes

:inner_prefix sits inside the field on the left; :inner_suffix replaces the default chevron, so render your own indicator if one is still needed.

.heex
<.tags_input name="tags" placeholder="Add tags..." options={@tags}>
  <:inner_prefix>
    <.icon name="hero-tag" class="icon" />
  </:inner_prefix>
</.tags_input>

<.tags_input name="filters" placeholder="Add filters..." options={@filters}>
  <:inner_suffix>
    <.icon name="hero-funnel" class="icon" />
  </:inner_suffix>
</.tags_input>

Outer affixes (input group)

:outer_prefix and :outer_suffix sit beside the field, sharing a border to form a single input group.

Filter
.heex
<.tags_input name="filter_tags" placeholder="Add filters..." options={@tags}>
  <:outer_prefix class="px-3 font-medium text-foreground bg-sunken/40">
    Filter
  </:outer_prefix>
</.tags_input>

<.tags_input name="apply_tags" placeholder="Add tags..." options={@tags}>
  <:outer_suffix>
    <.button>
      <.icon name="hero-arrow-right" class="icon" /> Apply
    </.button>
  </:outer_suffix>
</.tags_input>

:header and :footer stay pinned in the listbox while options scroll. Drop suggestion chips above the list, or a 'create new' action below it.

Validation errors

Pass errors as a list of strings to render messages and apply invalid styling. Bound forms populate this automatically once the input has been touched.

.heex
<.tags_input
  name="categories"
  label="Categories"
  errors={["Pick at least one category"]}
  options={@categories}
/>

Disabled

.heex
<.tags_input
  name="tags"
  label="Tags"
  disabled
  value={["phoenix", "liveview"]}
  options={@tags}
/>

Form integration

Bind to a Phoenix form field with field={f[:tags]}. Name, value, id, and errors are derived from the form, and a hidden multi-select keeps standard form submissions working. Every selection change round-trips through phx-change.

Pick one or more. The server sees every change.

Server received
tags: ["phoenix", "liveview"]
.heex
<.form :let={f} for={@form} phx-change="validate" phx-submit="save">
  <.tags_input
    field={f[:tags]}
    label="Topics"
    options={@tag_options}
    placeholder="Add topics..."
    searchable
    clearable
  />
</.form>
.ex
def handle_event("validate", %{"post" => params}, socket) do
  # Empty selections arrive as [""] from the include_hidden sentinel
  tags = Enum.reject(params["tags"] || [], &(&1 == ""))
  {:noreply, assign(socket, form: to_form(%{"tags" => tags}, as: :post))}
end

Selection limits with live count

Combine min and max with a server-rendered help_text count. The component disables remove buttons at the floor and silently rejects further selections at the ceiling; a real changeset enforces the same bounds on submit.

Pick between 1 and 5. The first tag's remove button is disabled at the floor.

2 / 5 selected
.heex
<.form :let={f} for={@form} phx-change="validate">
  <.tags_input
    field={f[:skills]}
    label="Skills"
    options={@skill_options}
    min={1}
    max={5}
    clearable
    help_text={"#{length(@form[:skills].value || [])} / 5 selected"}
  />
</.form>
.ex
# min/max on the component is a UX guard; enforce the same bounds
# server-side via a changeset.
def changeset(profile, attrs) do
  profile
  |> cast(attrs, [:skills])
  |> validate_length(:skills, min: 1, max: 5)
end

Creatable with server normalization

Pair typeable with creatable so pressing Enter on a non-matching value commits a new tag. The server slugifies the typed text, dedupes case-insensitively, and patches the option list back so the chip persists with a clean canonical value.

Type a tag and press Enter. The server slugifies new tags and rejects duplicates.

Stored slugs
[]
.heex
<.form :let={f} for={@form} phx-change="validate">
  <.tags_input
    field={f[:tags]}
    label="Article tags"
    options={@options}
    typeable
    creatable
    placeholder="Add a tag..."
  />
</.form>
.ex
def handle_event("validate", %{"article" => %{"tags" => _raw}}, socket) do
  # Pressing Enter appends a new <option value={text} selected> to the
  # hidden <select>. Slugify or normalize the raw text, dedupe, then
  # patch :options and the selection back.
  {:noreply, socket}
end

Pass on_search with a LiveView event name. The component debounces typing, shows a loading indicator, and patches the option list in place. The handler must always include currently-selected options in its response or the chips disappear on the next patch.

Cascading dependent fields

One field's value drives another's options. Selecting a department populates the team list from the server; switching department drops any previously-selected teams that no longer apply, and the listbox patches in place.

Options are populated by the server based on the selected department.

.heex
<.form :let={f} for={@form} phx-change="changed">
  <.select
    field={f[:department]}
    label="Department"
    options={@departments}
    clearable
  />

  <.tags_input
    field={f[:teams]}
    label="Teams"
    options={@teams}
    disabled={@form[:department].value in [nil, ""]}
    placeholder="Add teams..."
    searchable
  />
</.form>
.ex
def handle_event("changed", %{"assignment" => _params}, socket) do
  # Reassign :teams from the new department, and drop any previously
  # selected teams that no longer apply.
  {:noreply, socket}
end

Server-pushed selection

The server can swap the selection at any time by re-rendering the form with a new value. The component reconciles tags against the hidden <select> on every patch, so external triggers (preset buttons, suggestions, undo) update the chips without a remount.

The buttons above push a new selection from the server. The component reconciles tags on the next patch.

.heex
<.button type="button" phx-click="apply_preset" phx-value-preset="frontend">
  Frontend stack
</.button>

<.form :let={f} for={@form} phx-change="validate">
  <.tags_input
    field={f[:stack]}
    label="Project stack"
    options={@all_tags}
    typeable
    clearable
  />
</.form>
.ex
def handle_event("apply_preset", %{"preset" => _preset}, socket) do
  # Look up the preset values, then re-render the form with the new
  # selection. The component reconciles tags from the hidden <select>.
  {:noreply, socket}
end

Assignee picker (option and tag slots)

Combine :option for rich listbox rows, :tag for compact chips in the field, and on_search for a debounced server query. Always preserve currently-selected users in search responses so chips don't disappear mid-typing.

Rich rows in the listbox, compact chips with avatars in the field, server-driven search.

GH Grace Hopper LT Linus Torvalds
.heex
<.form :let={f} for={@form} phx-change="validate">
  <.tags_input
    field={f[:assignees]}
    label="Assignees"
    options={Enum.map(@users, &{&1.name, &1.id})}
    typeable
    on_search="search_users"
    search_threshold={1}
    debounce={200}
    placeholder="Search by name or email..."
  >
    <:option :let={{label, value}}>
      <% user = Enum.find(@all_users, &(&1.id == value)) %>
      <div class="flex items-center gap-2.5 px-2 py-1.5 rounded-base in-data-highlighted:highlight">
        <img src={user.avatar_url} class="size-7 rounded-full" alt="" />
        <div class="min-w-0">
          <div class="text-sm font-medium truncate">{label}</div>
          <div class="text-xs text-foreground-softer truncate">{user.email}</div>
        </div>
      </div>
    </:option>
    <:tag :let={{label, value}}>
      <% user = Enum.find(@all_users, &(&1.id == value)) %>
      <span class="inline-flex items-center gap-1.5">
        <img src={user.avatar_url} class="size-3.5 rounded-full" alt="" />
        <span>{label}</span>
      </span>
    </:tag>
  </.tags_input>
</.form>
.ex
def handle_event("search_users", %{"query" => _query}, socket) do
  # Run the search, then merge currently-selected users into the
  # response so the hidden <select> keeps them across patches.
  {:noreply, socket}
end