Flicker is a type-to-search, pick-one combobox for Phoenix LiveView that reads directly off an Ash resource — no options plumbing, no host web module, no ~p. This guide takes you from a fresh dependency to a working select in a form.

Requirements

  • Phoenix 1.8+. Flicker's client-side JS ships as a Phoenix.LiveView.ColocatedHook, which requires Phoenix ~> 1.8. A 1.7-era app fails to compile Flicker with a ColocatedHook requires at least {:phoenix, "~> 1.8"} error — bump Phoenix first.

  • esbuild that can resolve phoenix-colocated/*. The colocated-hook manifest is written under _build/<env>/phoenix-colocated, so esbuild's NODE_PATH must include the build directory. New Phoenix 1.8 apps set this up for you; if your config/config.exs doesn't already have it, add the build path to NODE_PATH:

    config :esbuild,
      my_app: [
        args: ~w(js/app.js --bundle ...),
        cd: Path.expand("../assets", __DIR__),
        # ↓ include the build dir so `phoenix-colocated/flicker` resolves
        env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
      ]

    Without it, mix assets.build fails with Could not resolve "phoenix-colocated/flicker".

Install

The fastest path is Igniter:

mix igniter.install flicker

This adds flicker to mix.exs, imports it into your .formatter.exs, adds config :flicker, default_limit: 25, default_debounce: 150 to config/config.exs, wires the keyboard-nav colocated hook into your assets/js/app.js (via igniter_js AST codemods — no manual JS wiring needed), and registers Flicker's templates as a Tailwind source so its utility classes survive the content purge (an @source directive on Tailwind v4, a content glob on v3).

Using the Flicker.Theme.daisy_ui/0 preset? Pass --daisyui to also add @plugin "daisyui"; to a Tailwind v4 app.css:

mix igniter.install flicker --daisyui

It's off by default — daisyUI is an opt-in preset, not a Flicker requirement, so the installer never forces a CSS framework on your app.

Installing by hand instead? Add the dependency:

def deps do
  [{:flicker, "~> 0.1"}]
end

then make the two edits the installer would have. First, wire the colocated hook into assets/js/app.js — it ships as a Phoenix.LiveView.ColocatedHook, aggregated per-dependency under its own manifest:

import {hooks as flickerHooks} from "phoenix-colocated/flicker"

const liveSocket = new LiveSocket("/live", Socket, {
  hooks: {...flickerHooks},
  // merge in your own colocated hooks too, if you have any:
  // hooks: {...colocatedHooks, ...flickerHooks},
  ...
})

Second, register Flicker's templates with Tailwind so its classes aren't purged. On Tailwind v4, add to assets/css/app.css:

@source "../../deps/flicker";

On Tailwind v3, add the glob to the content array in assets/tailwind.config.js:

content: [
  "../deps/flicker/**/*.*ex",
  // ...your existing globs
]

Your first select

The common case — a resource and a few field names, no provider module of your own — is Tier 1 declarative config:

<Flicker.select
  id="client-select"
  field={@form[:client_id]}
  resource={MyApp.Client}
  actor={@current_user}
  search={[:first_name, :last_name, :uci_number]}
  option_label={:full_name}
  option_sublabel={fn client -> "#{client.uci_number} · #{client.city}" end}
  read_action={:search}
  limit={20}
/>
  • resource — the Ash resource to read.
  • actor — passed straight through to Ash's authorizer and policies; a record the actor can't read never appears in results.
  • search — the attributes the typed text matches against.
  • option_label — an atom (a struct field) or a 1-arity function producing the row's display label.
  • option_sublabel — same shape, for the smaller secondary line.
  • read_action, sort, filter — optional; narrow or order what's searched.

Reaching for federated search across multiple sources, or a data source that isn't an Ash resource at all? Pass source (a module or {module, opts} implementing Flicker.Provider) instead of resource — see Flicker.Provider's moduledoc.

Form mode vs. controlled mode

Flicker.select/1 runs in exactly one of two modes, chosen by which attr you pass:

Form-field mode — pass field (a Phoenix.HTML.FormField, e.g. @form[:client_id]), as in the example above. The component owns its own hidden input and submits the selected value under that field's name. Required-field errors don't fire until the field is engaged, and the selection survives a LiveSocket reconnect. If your form is backed by AshPhoenix.Form, attach the adapter once in mount/3 so a selection merges into the form without wiping other fields' in-progress state:

def mount(_params, _session, socket) do
  {:ok,
   socket
   |> assign(form: to_form(AshPhoenix.Form.for_create(MyApp.Client, :create)))
   |> Flicker.AshPhoenixForm.attach(form: :form)}
end

Controlled mode — omit field and pass on_select (an atom) instead. No form inputs are rendered; your handle_info/2 receives {on_select, %Flicker.Result{} | nil} (nil on clear):

<Flicker.select
  id="global-search"
  source={MyApp.Search.Global}
  actor={@current_user}
  on_select={:result_selected}
/>
def handle_info({:result_selected, result}, socket) do
  {:noreply, assign(socket, :selected, result)}
end

Testing a select

Flicker.Test.search_select/3 drives a picker the way a user would — open it, type to filter, pick a result — from a PhoenixTest session:

test "picks a client", %{conn: conn} do
  conn
  |> visit(~p"/clients/new")
  |> Flicker.Test.search_select("client-select", "Casey Cassidy")
  |> assert_has("#client-select-input[value='Casey Cassidy']")
end

Requires phoenix_test in your own :test deps (most PhoenixTest users already have it).

Windowed search (infinite scroll)

The shipped default — "keep typing to narrow" — treats narrowing as the interaction: type more, see less, until you're at the record you want. That's the right model for most typeahead pickers. For a browsing-shaped population (a few hundred mostly-unfamiliar options the user wants to scan, not narrow), pass paginate:

<Flicker.select
  id="artist-select"
  field={@form[:artist_id]}
  resource={MyApp.Artist}
  actor={@current_user}
  search={[:name]}
  option_label={:name}
  paginate
/>

Scrolling to the tail of the listbox (or pressing ArrowDown on the last option) loads the next limit-sized window and appends it — no page numbers, no "next" button, just more results. max_windows (default 10) caps how many windows load before the tail falls back to the same "keep typing" hint the non-paginated default shows; a new query always resets back to the first window. paginate is false by default on both Flicker.select/1 and Flicker.palette/1 — turning it on is a per-picker UX decision, not a new global default (see Spec 010 for the full design).

Writing your own Flicker.Provider? search/2's opts gains an additive, optional :offset (default 0) — implement it and windowing works; ignore it and your provider keeps working exactly as it does today (core detects a provider that ignores :offset and stops asking after one extra probe, never looping forever).

Theming

Every visually distinct part of the rendered markup — the wrapper, the input, the listbox, an option, its active state, and more — has a named key in Flicker.Theme. Three presets ship: Flicker.Theme.vanilla/0 (plain, framework-free class names — the default), Flicker.Theme.tailwind/0, and Flicker.Theme.daisy_ui/0. Set one globally:

config :flicker, default_theme: Flicker.Theme.tailwind()

or per-component, with a full preset or a partial override of just the parts you want to change:

<Flicker.select theme={%{search_input: "my-custom-input"}} ... />

See Flicker.Theme's moduledoc for the full list of parts, and Flicker.Messages for overriding the (English-default) strings the component renders and announces.