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 aColocatedHook 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'sNODE_PATHmust include the build directory. New Phoenix 1.8 apps set this up for you; if yourconfig/config.exsdoesn't already have it, add the build path toNODE_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.buildfails withCould 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"}]
endthen 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)}
endControlled 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)}
endTesting 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']")
endRequires 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.