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:

FeatureSelectAutocomplete
Primary interactionClick to browseType to search
Data sizeSmall to medium listsAny size dataset
FilteringClient or server sideClient or server side
Multiple selectionSupportedNot supported
Clearable selectionSupportedSupported

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:

SizeHeightUse case
xs28pxCompact toolbars or table cells
sm32pxSecondary or supporting inputs
md36pxDefault size for most forms
lg40pxProminent fields
xl44pxHero 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.

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))}
end

When 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]
   )}
end

Open 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.

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
end

Task 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>
<.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

autocomplete(assigns)

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 the field id when bound to a form, then to name. 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 when field is not provided.

  • field (Phoenix.HTML.FormField) - Phoenix form field to bind to. When set, name, value, id, and errors are populated from the form, and validation messages are surfaced via Phoenix.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), and max-h-none lets 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 via Fluxon.Components.Form.label/1. Also used as the aria-label on 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 to nil.

  • autofocus (:boolean) - When true, sets the native autofocus attribute on the input so it receives focus on page load.

    Defaults to false.

  • disabled (:boolean) - When true, 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 and on_search is not dispatched. Defaults to 0 (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_state slot 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 fresh options list. 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.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: []}.

  • debounce (:integer) - Milliseconds to wait after the last keystroke before dispatching on_search. Has no effect on client-side filtering. Defaults to 200.

    Defaults to 200.

  • search_mode (:string) - Matching strategy for client-side filtering. Always case insensitive. Ignored when on_search is 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) - When true, the listbox opens as soon as the input gains focus (provided the current query meets search_threshold). When false, 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 when field is provided.

  • errors (:list) - List of validation messages to render below the field. The field is marked with data-invalid so error styling kicks in. Populated automatically from the form when field is provided.

    Defaults to [].

  • options (:list) (required) - Options shown in the listbox. Accepts the same shapes as Phoenix.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 :key and :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.
  • clearable (:boolean) - When true, renders a clear button inside the input whenever a value is selected. Clicking it resets the selection, restores every option, and dispatches a change event 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 when clearable is true.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 sets data-highlighted and data-selected on the wrapping element so styles can react via the in-data-highlighted: and in-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 from no_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.