Fluxon.Components.Autocomplete
(Fluxon v3.1.2)
Provides <.autocomplete>, a single-select combobox with type-to-search filtering, server-driven
search, and rich option rendering.
The autocomplete renders a text input paired with a floating listbox. As the user types, options are filtered either in the browser or by a LiveView event handler. The component covers grouped and nested option lists, custom option templates, header and footer chrome, custom empty states, size variants, inner and outer affixes, a clear button, and Phoenix form integration.
Select vs Autocomplete
Both components let users pick from a list, but the interaction model differs:
| Feature | Select | Autocomplete |
|---|---|---|
| Primary interaction | Click to browse | Type to search |
| Data size | Small to medium lists | Any size dataset |
| Filtering | Client or server side | Client or server side |
| Multiple selection | Supported | Not supported |
| Clearable selection | Supported | Supported |
Reach for Fluxon.Components.Select when users benefit from seeing the full list at once or
need multi-select. Reach for autocomplete when the list is large or when search has to run
server side.
Usage
Render an autocomplete with a name and an option list:
<.autocomplete
name="country"
options={[{"United States", "us"}, {"Canada", "ca"}]}
placeholder="Search countries..."
/>Add a label, description, and help text for richer form layouts:
<.autocomplete
name="deployment"
label="Deployment platform"
sublabel="Optional"
description="Determines the deployment configuration."
help_text="Choose from supported platforms."
placeholder="Select platform..."
options={["Fly.io", "Railway", "Heroku", "Render", "AWS"]}
/>Options
The options attribute accepts the same shapes as Phoenix.HTML.Form.options_for_select/2:
<!-- Strings (label and value are the same) -->
<.autocomplete name="fruits" options={["Apple", "Banana", "Cherry"]} />
<!-- {label, value} tuples -->
<.autocomplete name="countries" options={[{"United States", "us"}, {"Canada", "ca"}]} />
<!-- Keyword pairs (atom: value) -->
<.autocomplete name="languages" options={[english: "en", spanish: "es", french: "fr"]} />
<!-- Keyword lists with explicit :key/:value (and optional :disabled) -->
<.autocomplete
name="roles"
options={[[key: "Admin", value: "admin"], [key: "Editor", value: "editor", disabled: true]]}
/>
<!-- Atoms or integers (used as both label and value) -->
<.autocomplete name="priority" options={[:low, :medium, :high]} />
<!-- Ranges -->
<.autocomplete name="page" options={1..10} />Grouped and Nested Options
Wrap options in {group_label, children} tuples to render grouped lists. Groups can be nested to
arbitrary depth, and each level receives a CSS --depth variable that increases the visual
indentation of group labels and options:
<.autocomplete
name="city"
options={[
{"Europe", [
{"France", [{"Paris", 1}, {"Lyon", 2}]},
{"Italy", [{"Rome", 3}, {"Milan", 4}]}
]},
{"Asia", [
{"China", [{"Beijing", 5}, {"Shanghai", 6}]}
]}
]}
placeholder="Search cities..."
/>Group containers automatically hide themselves when none of their children match the active search filter, so the listbox does not display empty group headers.
Disabled Options
Pass disabled: true in the keyword-list option form to render an option in a non-interactive
state. Disabled options are visually muted, cannot be selected by click or keyboard, and are
skipped during arrow-key navigation.
<.autocomplete
name="role"
options={[
[key: "Admin", value: "admin"],
[key: "Editor", value: "editor", disabled: true],
[key: "Viewer", value: "viewer"]
]}
/>Sizes
The size attribute scales the input height, padding, font size, and any icons inside affix
slots:
| Size | Height | Use case |
|---|---|---|
xs | 28px | Compact toolbars or table cells |
sm | 32px | Secondary or supporting inputs |
md | 36px | Default size for most forms |
lg | 40px | Prominent fields |
xl | 44px | Hero sections and primary actions |
<.autocomplete name="size_xs" size="xs" options={@options} />
<.autocomplete name="size_md" size="md" options={@options} />
<.autocomplete name="size_xl" size="xl" options={@options} />Search Behavior
By default, filtering runs in the browser against the rendered options. Provide on_search to
hand control over to a LiveView event handler instead.
Selection is keyboard driven. Typing filters the list (or fires on_search after the debounce).
Pressing Down or Up opens the listbox and highlights the currently selected option, falling
back to the first or last visible option; while open, Down and Up move the highlight one
option at a time and clamp at the ends without wrapping, and Home and End jump to the first
and last visible option. Enter selects the highlighted option, closes the listbox, and
refocuses the input; when the listbox is closed Enter falls through so the surrounding form can
submit. Escape closes the listbox and returns focus to the input. Moving focus out of the field
with Tab, clicking outside the component, or hovering an option all update state accordingly
(hover moves the highlight, focus-out and outside clicks close the listbox).
Search Modes
The search_mode attribute controls how the typed query is matched against each option label
during client-side filtering. Matching is case insensitive in every mode:
"contains"(default): the query appears anywhere in the label."starts-with": the label starts with the query."exact": the label equals the query.
<.autocomplete
name="languages"
search_mode="starts-with"
options={["JavaScript", "TypeScript", "CoffeeScript", "ReScript"]}
/>Search Threshold
Use search_threshold to require a minimum number of typed characters before the listbox opens
and filtering begins. While the trimmed query is shorter than the threshold the listbox stays
closed, no client-side filtering runs, and no on_search event is dispatched:
<.autocomplete
name="movies"
search_threshold={2}
placeholder="Type at least 2 characters..."
options={@movies}
/>Clearing the input always resets the selection and closes the listbox, regardless of threshold.
Server-Side Search
Pass on_search with the name of a LiveView event to filter on the server. The component
debounces the input and dispatches the event with %{"query" => query, "id" => component_id}.
A loading indicator appears while the request is in flight and is replaced by the new options or
by an error message if the event fails:
<.autocomplete
field={f[:movie]}
options={@movies}
on_search="search_movies"
search_threshold={2}
debounce={300}
placeholder="Search movies..."
/>def handle_event("search_movies", %{"query" => query}, socket) do
{:noreply, assign(socket, movies: MyApp.Movies.search(query))}
endWhen the request completes, the highlight is reset to the first visible option (or -1 if the
list is empty) and the empty-state text is rendered using whatever query is in the input at reply
time, so a slow stale response cannot show the wrong "no results" text.
Keep the Selected Option in Initial Results
The visible label inside the input is derived from the rendered options list. With server
side search, if the currently selected option is not in the initial options the input will
show the raw value instead of the human-readable label. Always include the selected option in
the initial assigns:
def mount(_params, _session, socket) do
selected_user = MyApp.Accounts.get_user!(2)
featured = MyApp.Accounts.featured_users() |> Enum.map(&{&1.name, &1.id})
{:ok,
assign(socket,
selected_user_id: selected_user.id,
users: [{selected_user.name, selected_user.id} | featured]
)}
endOpen on Focus
By default the listbox opens once the user types past the search threshold. Set open_on_focus
to open the listbox whenever the input is focused, even with an empty query (the threshold is
still respected):
<.autocomplete
name="department"
open_on_focus
options={["Engineering", "Design", "Sales", "Support"]}
/>Clearable Selection
Set clearable to render a clear button inside the input when a value is selected. Clicking it
resets the selection and re-shows every option (any active search filter is wiped). The clear
button is hidden while no value is selected and while the input is disabled:
<.autocomplete
name="status"
clearable
value="active"
options={[{"Active", "active"}, {"Archived", "archived"}]}
/>Form Integration
Bind the autocomplete to a Phoenix form field for automatic value, name, and error handling:
<.form :let={f} for={@form} phx-change="validate" phx-submit="save">
<.autocomplete
field={f[:assigned_to_id]}
label="Assigned to"
options={@users}
placeholder="Search users..."
/>
</.form>When field is provided, the component derives name, value, id, and any validation errors
reported via Phoenix.Component.used_input?/1. Errors are rendered with Fluxon.Components.Form.error/1
beneath the field. Selecting or clearing an option updates a hidden input that carries the form
value and dispatches a native change event, so a surrounding phx-change form picks up the new
selection automatically. For ad-hoc usage without a form, pass name and value directly:
<.autocomplete
name="search_user"
value={@user_id}
options={@users}
placeholder="Search users..."
/>Affixes
The component supports four affix slots arranged around the input. Inner affixes share the input border; outer affixes sit beside the field with their borders merged into a single input group.
<!-- Inner prefix icon -->
<.autocomplete name="search" options={@results} placeholder="Search...">
<:inner_prefix>
<.icon name="hero-magnifying-glass" class="size-4" />
</:inner_prefix>
</.autocomplete>
<!-- Outer suffix action button -->
<.autocomplete name="invite" options={@users} placeholder="Search users...">
<:inner_prefix>
<.icon name="hero-user-plus" class="size-4" />
</:inner_prefix>
<:outer_suffix>
<.button variant="solid" color="primary" size="md">
<.icon name="hero-paper-airplane" class="size-4" /> Invite
</.button>
</:outer_suffix>
</.autocomplete>Match Affix Sizes
When buttons or other sized components live inside affix slots, set their size attribute to
the same value as the autocomplete so the borders and heights line up. For example, with
size="lg" on the autocomplete, use size="lg" on the button as well.
Custom Option Rendering
Provide an :option slot to take full control of each row in the listbox. The slot receives a
{label, value} tuple. Two data attributes are toggled on the wrapping <button> so styling can
react to interaction state without writing JavaScript:
[data-highlighted]is set on the option currently highlighted by keyboard or hover.[data-selected]is set on the option whose value matches the current selection.
Use the in-data-highlighted: and in-data-selected: Tailwind variants to style descendants:
<.autocomplete name="users" options={@users} placeholder="Search team members...">
<:option :let={{label, value}}>
<div class="flex items-center gap-3 p-2 rounded-lg in-data-highlighted:bg-zinc-100 in-data-selected:bg-blue-50">
<div class="size-8 rounded-full bg-blue-500 text-white grid place-items-center text-sm font-semibold">
{String.first(label)}
</div>
<div>
<div class="font-medium text-sm">{label}</div>
<div class="text-xs text-zinc-500">@{value}</div>
</div>
</div>
</:option>
</.autocomplete>When the slot is omitted, options render with a default template that shows the label and a checkmark icon for the selected entry.
Header, Footer, and Empty State
Add static content above or below the option list with the :header and :footer slots. They
appear inside the listbox and remain visible regardless of search results, so they are useful
for filter chips, "create new" buttons, or contextual links.
<.autocomplete field={f[:product]} options={@products}>
<:header class="p-2 border-b border-base">
<div class="flex gap-2">
<.button size="xs" class="rounded-full" phx-click="filter" phx-value-type="all">All</.button>
<.button size="xs" class="rounded-full" phx-click="filter" phx-value-type="active">Active</.button>
</div>
</:header>
<:footer class="p-2 border-t border-base">
<.button type="button" size="sm" class="w-full" variant="ghost">
<.icon name="hero-plus" class="size-4" /> Create new
</.button>
</:footer>
</.autocomplete>Customize the "no results" copy with no_results_text. Use %{query} as a placeholder for the
user's current input:
<.autocomplete
name="users"
no_results_text="No users match '%{query}'."
options={@users}
/>For richer empty states, provide an :empty_state slot. When present, it replaces the default
text-only message:
<.autocomplete name="contacts" options={@contacts}>
<:empty_state>
<div class="p-4 text-center text-foreground-softer">
<.icon name="hero-face-frown" class="size-8 mx-auto mb-2" />
<p class="font-medium">No matches</p>
<p class="text-sm">Try a different search term.</p>
</div>
</:empty_state>
</.autocomplete>States
Set disabled to render the field in a non-interactive state. The clear button is hidden, the
listbox cannot be opened, and the field ignores every user interaction:
<.autocomplete
name="frozen"
disabled
value="Option 2"
options={["Option 1", "Option 2", "Option 3"]}
/>Pass a list of strings to errors to render validation messages below the field. The field gains
a data-invalid attribute that drives error styling. When using field, errors are populated
automatically from the form's changeset:
<.autocomplete
name="email"
label="Email"
errors={["This field is required", "Must be a valid email"]}
options={@suggestions}
/>Constraining the Field Width
By default the field fills its container. To narrow only the input while leaving the label,
description, and help text at their natural width, target [data-part=field-root] from any
ancestor:
<div class="**:data-[part=field-root]:max-w-xs">
<.autocomplete label="Search" options={@results} />
</div>This pattern works uniformly across Fluxon input-shaped form components.
Examples
Server-Driven User Search with Custom Rows
defmodule MyApp.UsersLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(users: fetch_users())
|> assign(form: to_form(%{}, as: :search))}
end
def render(assigns) do
~H"""
<.form :let={f} for={@form} phx-change="search">
<.autocomplete
field={f[:user_id]}
options={user_options(@users)}
on_search="search_users"
search_threshold={2}
clearable
>
<:option :let={{label, value}}>
<div class="flex items-center gap-3 p-2 rounded-lg in-data-highlighted:bg-zinc-100 in-data-selected:bg-blue-50">
<img src={user_by_id(value, @users)["image"]} class="size-8 rounded-full" />
<div>
<div class="font-medium text-sm">{label}</div>
<div class="text-xs text-zinc-500">{user_by_id(value, @users)["email"]}</div>
</div>
</div>
</:option>
<:empty_state>
<div class="p-4 text-center text-zinc-500">
<p>No matching users found</p>
<p class="text-sm">Try searching by name or email.</p>
</div>
</:empty_state>
</.autocomplete>
</.form>
"""
end
def handle_event("search_users", %{"query" => query}, socket) do
{:noreply, assign(socket, users: fetch_users(query))}
end
endTask Assignment with Avatars and Threshold
<.autocomplete
name="assigned_user"
label="Assign to"
description="Pick a team member to assign this task."
placeholder="Search by name or email..."
clearable
search_threshold={2}
options={[
{"John Doe (john@example.com)", "john"},
{"Jane Smith (jane@example.com)", "jane"},
{"Bob Johnson (bob@example.com)", "bob"}
]}
>
<:inner_prefix>
<.icon name="hero-magnifying-glass" class="size-4" />
</:inner_prefix>
<:option :let={{label, _value}}>
<div class="flex items-center gap-3 p-2">
<div class="size-8 rounded-full bg-blue-500 grid place-items-center text-white text-sm font-bold">
{String.first(label)}
</div>
<div class="flex-1">
<div class="font-medium text-sm">{String.split(label, " (") |> hd()}</div>
<div class="text-xs text-zinc-500">
{String.split(label, "(") |> Enum.at(1, "") |> String.replace(")", "")}
</div>
</div>
</div>
</:option>
</.autocomplete>Grouped Catalog with Header, Footer, and Outer Action
<.autocomplete
name="product_search"
label="Product catalog"
sublabel="Required"
description="Choose a product from the catalog."
help_text="Start typing to search through the inventory."
placeholder="Search products..."
clearable
search_threshold={3}
options={[
{"Electronics", [
{"iPhone 15 Pro", "iphone15pro"},
{"MacBook Air M2", "macbook-air-m2"}
]},
{"Clothing", [
{"Men's T-Shirt", "mens-tshirt"},
{"Winter Jacket", "winter-jacket"}
]}
]}
>
<:inner_prefix>
<.icon name="hero-magnifying-glass" class="size-4" />
</:inner_prefix>
<:outer_suffix>
<.button size="md">Browse</.button>
</:outer_suffix>
<:header class="p-2 border-b border-base bg-zinc-50">
<div class="text-sm text-zinc-600">Popular products shown first</div>
</:header>
<:footer class="p-2 border-t border-base">
<.button type="button" size="sm" class="w-full" variant="ghost">
Can't find it? <span class="text-blue-600">Contact support</span>
</.button>
</:footer>
</.autocomplete>
Summary
Components
Renders an autocomplete (single-select combobox) with type-to-search filtering.
Components
Renders an autocomplete (single-select combobox) with type-to-search filtering.
Use this component when users need to pick one option from a list that is too large to scan
visually, or whenever the option list has to come from the server. Filtering runs in the browser
by default and switches to a LiveView event when on_search is provided. The component supports
Phoenix form binding through field, grouped and nested options, custom option templates,
header and footer chrome, custom empty states, inner and outer affixes, and a clear button.
Attributes
id(:any) - Unique DOM id for the root element. Falls back to thefieldid when bound to a form, then toname. The input, listbox, and hidden value input derive their ids from this base.Defaults to
nil.name(:any) - Form name submitted with the selected value. Required whenfieldis not provided.field(Phoenix.HTML.FormField) - Phoenix form field to bind to. When set,name,value,id, anderrorsare populated from the form, and validation messages are surfaced viaPhoenix.Component.used_input?/1.class(:any) - Extra CSS classes applied to the input wrapper (label[data-part=field]) and the listbox container. Use it to tweak spacing, borders, or layout without restyling internals.A
max-h-*class overrides the listbox's default height cap (max-h-72), andmax-h-nonelets the panel use all the space on whichever side it opens; either way it still shrinks when the viewport is short. Note that the class also lands on the input wrapper, so prefer height caps large enough to be irrelevant to a single-line field. Width classes affect the field only: the listbox always sizes to its content, at least as wide as the field.Defaults to
nil.label(:string) - Text rendered above the input viaFluxon.Components.Form.label/1. Also used as thearia-labelon the underlying combobox input.Defaults to
nil.sublabel(:string) - Secondary label text rendered next to the main label, typically for hints like"Required"or"Optional".Defaults to
nil.help_text(:string) - Supporting text rendered below the field. Use it for guidance that should remain visible regardless of validation state.Defaults to
nil.description(:string) - Longer descriptive text rendered below the label and above the input. Use it for usage guidance or context that doesn't fit in the label.Defaults to
nil.placeholder(:string) - Placeholder shown inside the input when no query has been typed and no value is selected. Defaults tonil.autofocus(:boolean) - Whentrue, sets the nativeautofocusattribute on the input so it receives focus on page load.Defaults to
false.disabled(:boolean) - Whentrue, disables the input and hides the clear button. The field is rendered with muted colors, the listbox cannot open, and the field ignores user interaction.Defaults to
false.size(:string) - Controls the field height, padding, font size, and the size of icons inside affix slots."xs": 28px tall. Use in dense toolbars or table cells."sm": 32px tall. Use for secondary or supporting inputs."md": 36px tall. Default size for most forms."lg": 40px tall. Use for prominent fields."xl": 44px tall. Use in hero sections or primary call-to-action layouts.
Defaults to
"md". Must be one of"xs","sm","md","lg", or"xl".search_threshold(:integer) - Minimum number of characters (after trimming whitespace) required before the listbox opens and filtering runs. Below the threshold the typed query is tracked but no client-side filtering happens andon_searchis not dispatched. Defaults to0(filter on every keystroke).Defaults to
0.no_results_text(:string) - Text shown inside the listbox when no option matches the active query. Use%{query}as a placeholder for the user's input. Ignored when an:empty_stateslot is provided.Defaults to
"No results found for \"%{query}\".".on_search(:string) - Name of a LiveView event to dispatch on every search. When set, client-side filtering is disabled and the LiveView is responsible for assigning a freshoptionslist. The event receives%{"query" => query, "id" => component_id}. A loading indicator is rendered while the event is pending.Defaults to
nil.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: []}.debounce(:integer) - Milliseconds to wait after the last keystroke before dispatchingon_search. Has no effect on client-side filtering. Defaults to200.Defaults to
200.search_mode(:string) - Matching strategy for client-side filtering. Always case insensitive. Ignored whenon_searchis set."contains": matches when the label includes the query (default)."starts-with": matches when the label starts with the query."exact": matches only when the label equals the query.
Defaults to
"contains". Must be one of"contains","starts-with", or"exact".open_on_focus(:boolean) - Whentrue, the listbox opens as soon as the input gains focus (provided the current query meetssearch_threshold). Whenfalse, the listbox opens only after the user types or presses an arrow key.Defaults to
false.value(:any) - Currently selected value. Determines which option is marked as selected and which label is shown inside the input. Populated automatically whenfieldis provided.errors(:list) - List of validation messages to render below the field. The field is marked withdata-invalidso error styling kicks in. Populated automatically from the form whenfieldis provided.Defaults to
[].options(:list) (required) - Options shown in the listbox. Accepts the same shapes asPhoenix.HTML.Form.options_for_select/2:- List of strings:
["Apple", "Banana"]. - List of
{label, value}tuples:[{"United States", "us"}]. - Keyword pairs:
[admin: "admin", user: "user"]. - Keyword lists with explicit
:keyand:value. - Atoms or integers (used as both label and value).
- Ranges, including stepped ranges like
1..12//-1. - Grouped or nested groups via
{group_label, children}tuples.
- List of strings:
clearable(:boolean) - Whentrue, renders a clear button inside the input whenever a value is selected. Clicking it resets the selection, restores every option, and dispatches achangeevent on the hidden input.Defaults to
false.Global attributes are accepted. Additional HTML attributes forwarded to the hidden input element that carries the form value. Supports all globals plus:
["form"].
Slots
inner_prefix- Content rendered inside the input's border, before the text. Ideal for icons or short text prefixes. The slot can be used multiple times; each entry becomes its own affix container.Accepts attributes:class(:any) - Extra CSS classes applied to the inner prefix container.
outer_prefix- Content rendered outside the input border, before the field, with borders merged into a single input group. Use for labels, dropdowns, or buttons that sit beside the field.Accepts attributes:class(:any) - Extra CSS classes applied to the outer prefix container.
inner_suffix- Content rendered inside the input's border, after the text. Use for icons, status indicators, or auxiliary controls. Renders alongside the clear button whenclearableistrue.Accepts attributes:class(:any) - Extra CSS classes applied to the inner suffix container.
outer_suffix- Content rendered outside the input border, after the field, with borders merged into a single input group. Common for action buttons paired with the search field.Accepts attributes:class(:any) - Extra CSS classes applied to the outer suffix container.
option- Custom template for each option row. The slot receives a{label, value}tuple. The component setsdata-highlightedanddata-selectedon the wrapping element so styles can react via thein-data-highlighted:andin-data-selected:Tailwind variants. Omit the slot to fall back to the default label-and-checkmark template.Accepts attributes:class(:any) - Extra CSS classes applied to the option container.
empty_state- Custom content rendered inside the listbox when no option matches the query. Replaces the default text-only message derived fromno_results_text.Accepts attributes:class(:any) - Extra CSS classes applied to the empty state container.
header- Static content rendered at the top of the listbox, above the option list. Visible whenever the listbox is open, regardless of search results. Useful for filter chips or contextual helpers.Accepts attributes:class(:any) - Extra CSS classes applied to the header container.
footer- Static content rendered at the bottom of the listbox, below the option list. Visible whenever the listbox is open, regardless of search results. Useful for "create new" actions or links to a fuller view.Accepts attributes:class(:any) - Extra CSS classes applied to the footer container.