Combobox
A searchable select with the keyboard grammar of a command palette and the form behavior of a native select. No JS dependencies - one hook, one hidden select.
Replaces the Tom Select combobox
<.field>
support. Still on the old one? The
legacy docs
stay up, and the migration table at the bottom of this page maps every old attr to its new home.
How it works
The visible input is chrome. A hidden native <select>
carries the name and value, so changesets, phx-change, form recovery and
required
validation behave exactly like a plain select - the server never learns a keystroke, only
choices. Keyboard and filtering run client-side in one hook: prefix beats word-boundary beats
substring beats fuzzy, arrow keys wrap through an empty stop, and Escape, Tab and taps away
all close. WAI-ARIA combobox roles and a polite results live region come built in.
The searchable select
Type to filter, arrow keys to move, Enter to choose - the command palette's keyboard machinery on a form control. The visible input is chrome; a hidden native select carries the name and value, so changesets, phx-change and LiveView form recovery behave exactly like a plain select. Zero JS dependencies. Emoji in a label is just text - flags need no slot, no assets, and filtering still matches the country name.
<div class="w-full max-w-xs mx-auto">
<.combo_box
id="sx-combo-basic"
name="country"
placeholder="Select a country…"
clearable
options={[
{"🇦🇺 Australia", "au"},
{"🇯🇵 Japan", "jp"},
{"🇳🇿 New Zealand", "nz"},
{"🇵🇹 Portugal", "pt"},
{"🇸🇪 Sweden", "se"}
]}
/>
</div>
Chips - multiple selection
multiple turns the trigger into a chip row: every choice renders as a removable token, the panel stays open while picking, and Backspace in an empty input removes the last chip. The hidden select becomes a real select multiple - its name gains [] - so every choice survives the form post exactly like a native multiple select. max_items caps the count; at the cap, unchosen options rest until something is removed.
<div class="w-full max-w-sm mx-auto">
<.combo_box
id="sx-combo-multi"
name="stack"
multiple
max_items={4}
value={["phx", "lv"]}
placeholder="Build your stack…"
options={[
{"Phoenix", "phx"},
{"LiveView", "lv"},
{"Ecto", "ecto"},
{"Oban", "oban"},
{"Tailwind", "tw"},
{"Postgres", "pg"}
]}
/>
</div>
A chosen value, clearable
value (or the form field's value) marks the chosen option: it renders in the trigger, carries aria-selected and the check mark, and the highlight homes on it when the panel opens. clearable adds an X button whenever a value is chosen - one press empties the selection. Options are label/value tuples here - the shapes select accepts all work.
<div class="w-full max-w-xs mx-auto">
<.combo_box
id="sx-combo-chosen"
name="tz"
value="au_syd"
clearable
options={[
{"Sydney", "au_syd"},
{"Tokyo", "jp_tyo"},
{"Lisbon", "pt_lis"},
{"Stockholm", "se_sto"}
]}
/>
</div>
Groups and disabled options
{group_label, options} renders a heading and keeps its position between flat options; a group hides itself when the query filters out every option inside. {label, value, disabled: true} renders the option present but inert - visible in the list, skipped by the keyboard.
<div class="w-full max-w-xs mx-auto">
<.combo_box
id="sx-combo-groups"
clearable
name="city"
placeholder="Pick a city…"
options={[
{"Oceania", [{"Sydney", "syd"}, {"Auckland", "akl"}]},
{"Europe", [{"Lisbon", "lis"}, {"Stockholm", "sto", disabled: true}]}
]}
/>
</div>
The picker - trigger variant
variant="trigger" is the select-like anatomy: a button shows the chosen value (or a count with multiple), and the search input lives inside the panel. This is the shape pickers and the data table's filter editors use - open it from anywhere, search, choose, and focus returns to the button. Same hidden select underneath, same form behavior.
<div class="flex flex-col items-center w-full max-w-xs gap-4 mx-auto">
<.combo_box
id="sx-combo-trigger"
name="assignee"
clearable
variant="trigger"
placeholder="Assign to…"
options={[
{"Amelia Ward", "amelia"},
{"Jonah Reyes", "jonah"},
{"Priya Anand", "priya"},
{"Tom Hale", "tom"}
]}
/>
<.combo_box
id="sx-combo-trigger-multi"
name="labels"
variant="trigger"
multiple
value={["bug", "ui"]}
placeholder="Labels…"
count_label="labels"
options={[
{"Bug", "bug"},
{"UI", "ui"},
{"Docs", "docs"},
{"Performance", "perf"}
]}
/>
</div>
Create new options - free text
create offers a keyboard-reachable "Create" row whenever the query matches no option - Enter at the empty stop commits typed text too (free_text alone gives you that without the row). The committed value becomes a real option in the hidden select, so the form posts it; the server owns whether it persists. An existing label is chosen instead of duplicated, case-insensitively.
<div class="w-full max-w-sm mx-auto">
<.combo_box
id="sx-combo-create"
name="tags"
multiple
create
placeholder="Add tags…"
options={["elixir", "phoenix", "liveview"]}
/>
</div>
Rich options - the :option slot
The :option slot renders anything inside each panel option - avatars, flags, secondary text - with :let receiving the normalized option (label, value, disabled, and meta: whatever extra data the option tuple carried). Filtering, chips and the trigger label keep using the plain label, so rich content never affects search or the closed state.
<div class="w-full max-w-sm mx-auto">
<.combo_box
id="sx-combo-rich"
name="assignee"
clearable
placeholder="Assign to…"
options={[
{"Amelia Ward", "amelia", role: "Engineering"},
{"Jonah Reyes", "jonah", role: "Design"},
{"Priya Anand", "priya", role: "Support"},
{"Tom Hale", "tom", role: "Engineering"}
]}
>
<:option :let={opt}>
<.avatar size="xs" name={opt.label} random_gradient />
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate">{opt.label}</span>
<span class="truncate text-xs text-gray-500 dark:text-gray-400">{opt.meta[:role]}</span>
</span>
</:option>
</.combo_box>
</div>
Label picker - the :selected slot
The :selected slot renders rich CLOSED-state content in the trigger - here colored dots with a +N overflow, pure composition, no overflow attr. :let receives the list of chosen normalized options (label, value, meta). Client-side picks show the plain count until the LiveView patch re-renders the slot (server wins); a preset value shows the rich state immediately.
<div class="w-full max-w-xs mx-auto">
<.combo_box
id="sx-combo-labels"
name="labels"
variant="trigger"
multiple
placeholder="Labels…"
count_label="labels"
value={["feat", "bug", "imp", "des"]}
options={[
{"Feature", "feat", color: "#0ea5e9"},
{"Bug", "bug", color: "#f43f5e"},
{"Improvement", "imp", color: "#10b981"},
{"Design", "des", color: "#a855f7"},
{"Docs", "docs", color: "#f59e0b"}
]}
>
<:selected :let={chosen}>
<span
:for={opt <- Enum.take(chosen, 3)}
class="h-3 w-3 shrink-0 rounded-full"
style={"background-color: #{opt.meta[:color]}"}
></span>
<span :if={length(chosen) == 1} class="truncate">{hd(chosen).label}</span>
<span :if={length(chosen) > 3} class="text-xs tabular-nums text-gray-500 dark:text-gray-400">
+{length(chosen) - 3}
</span>
</:selected>
<:option :let={opt}>
<span
class="h-2.5 w-2.5 shrink-0 rounded-full"
style={"background-color: #{opt.meta[:color]}"}
></span>
<span class="truncate">{opt.label}</span>
</:option>
</.combo_box>
</div>
Avatar chips - the :chip slot
The :chip slot renders rich chip content - the remove button stays appended. Client-side picks build plain optimistic chips until the LiveView patch swaps the rich ones back in (server wins on patch); server-rendered chips are left intact whenever they already match the selection.
<div class="w-full max-w-sm mx-auto">
<.combo_box
id="sx-combo-team"
name="team"
multiple
placeholder="Add members…"
value={["amelia", "jonah"]}
options={[
{"Amelia Ward", "amelia", role: "Engineering"},
{"Jonah Reyes", "jonah", role: "Design"},
{"Priya Anand", "priya", role: "Support"},
{"Tom Hale", "tom", role: "Engineering"}
]}
>
<:chip :let={opt}>
<.avatar size="2xs" name={opt.label} random_gradient />
<span class="truncate">{opt.label}</span>
</:chip>
<:option :let={opt}>
<.avatar size="xs" name={opt.label} random_gradient />
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate">{opt.label}</span>
<span class="truncate text-xs text-gray-500 dark:text-gray-400">{opt.meta[:role]}</span>
</span>
</:option>
</.combo_box>
</div>
Panel chrome - :header and :footer
Panel chrome lives OUTSIDE the listbox: a caption above the options, a summary or manage link below. Keyboard navigation and filtering never touch either - options stay the only stops.
<div class="w-full max-w-xs mx-auto">
<.combo_box
id="sx-combo-chrome"
name="dest"
placeholder="Where to?"
options={[
{"🇯🇵 Tokyo", "tyo"},
{"🇵🇹 Lisbon", "lis"},
{"🇸🇪 Stockholm", "sto"},
{"🇦🇺 Sydney", "syd"},
{"🇰🇷 Seoul", "sel"}
]}
>
<:header>Popular destinations</:header>
<:footer>
<span class="flex items-center justify-between">
<span>5 cities</span>
<span class="font-medium text-primary-600 dark:text-primary-400">Manage list</span>
</span>
</:footer>
</.combo_box>
</div>
Remote search - your LiveView as the data source
Typing pushes a debounced event with the raw search term; your handler replies with results
and the hook renders them - loading row, stale-reply protection and all. The contract is
unchanged from the Tom Select era, so existing handlers keep working. Pass
remote_options_target={@myself}
when the handler lives on a LiveComponent. This demo searches ~150 countries server-side:
def handle_event("country_search", term, socket) do
results =
MyApp.search_countries(term)
|> Enum.map(&%{text: &1.name, value: &1.id})
{:reply, %{results: results}, socket}
end
In forms - field citizenship
<.field type="combobox">
brings label, changeset errors and help_text. The field size family maps onto the combobox
(sm/md/lg); md
is pixel-matched to every other input, so mixed forms stay cohesive - reserve sm
for dense chrome like table toolbars. Rich slots stay on the bare component.
<.field
type="combobox"
field={@form[:country]}
label="Country"
help_text="Where you're based"
clearable
options={@country_options}
/>
Rich closed states and the server-wins contract
The :selected
and :chip
slots render server-side. Client picks show plain optimistic text for one round trip, then
the LiveView patch swaps the rich content back in - chips upgrade instantly via per-option
templates, so in practice only the trigger label ever shows the plain fallback, and only for
~one round trip. The consequence worth knowing: a combobox outside
a phx-change
form has no patches, so its :selected
label falls back to plain text after interaction. In a real app your combobox lives in a
form and this never comes up - but now you know why the rule exists.
Keyboard
↓/↑
open and move (wrapping through an empty stop - in free-text mode Enter
there commits what you typed), Home/End
jump, Enter
chooses the highlighted option, Backspace
in an empty input removes the last chip, Escape
closes, Tab
moves on. In the trigger variant, ↓/↑
on the button opens straight into the panel search.
Properties
Every attr and slot on combo_box/1, read straight off the component at render
time - so this table can't drift from the real API.
| Attribute | Type | Default | Description |
|---|---|---|---|
class
|
any |
nil
|
extra classes for the wrapper |
clear_label
|
string |
"Clear selection"
|
aria-label for the clear button |
clearable
|
boolean |
false
|
single select only: show a clear button in the control when a value is chosen |
count_label
|
string |
"selected"
|
trigger variant with multiple: the word after the count in the closed label |
create
|
boolean |
false
|
free_text plus an explicit "create" row in the panel: when the query matches no option label exactly, a keyboard-reachable row offers to create it. Implies free_text's commit behavior. |
create_label
|
string |
"Create"
|
the create row's verb, localizable |
disabled
|
boolean |
false
|
|
field
|
{:struct, Phoenix.HTML.FormField} |
nil
|
a form field, e.g. f[:country] - supplies name, value and id |
form_id
|
string |
nil
|
the form this control belongs to when rendered outside it (the select's form attribute) |
free_text
|
boolean |
false
|
typed text is a committable value: Enter with no highlighted option commits the query itself (the empty-stop keyboard grammar's payoff). The committed value is inserted into the hidden select as a dynamic option, so forms post it like any other choice - the server owns whether it persists (re-render it in options to keep it). |
id
|
string |
nil
|
unique id; the PetalComboBox hook mounts here. Derived from field or name when absent |
listbox_label
|
string |
"Options"
|
accessible name for the listbox |
loading_label
|
string |
"Searching…"
|
the remote loading row's text, localizable |
max_items
|
integer |
nil
|
multiple only: cap on chosen options; at the cap, unchosen options render inert |
multiple
|
boolean |
false
|
chip-row selection: the hidden select becomes select multiple and the name gains [] |
name
|
string |
nil
|
input name, when not using field |
no_results_text
|
string |
"No results found"
|
|
options
|
list |
[]
|
strings, {label, value} tuples, {label, value, opts} (opts: disabled: true), or {group_label, options} groups |
placeholder
|
string |
"Select an option…"
|
|
remote_options_event_name
|
string |
nil
|
use your LiveView as a remote data source: typing pushes this event (debounced) with the search term as the payload, exactly like the Tom Select-era contract. Handle it and reply with results: def handle_event("search", term, socket) do results = MyApp.search(term) |> Enum.map(&%{text: &1.name, value: &1.id}) {:reply, %{results: results}, socket} end The hook renders the results as the option list (the listbox becomes hook-owned in remote mode); a chosen result is inserted into the hidden select so the form posts it. Requires a LiveView socket. |
remote_options_target
|
any |
nil
|
the event's target - pass @myself when the handler lives on a LiveComponent |
remove_label
|
string |
"Remove"
|
aria-label prefix for chip remove buttons; the option label is appended |
required
|
boolean |
false
|
renders on the hidden select, so native required validation guards the real control |
rest
|
global |
||
results_label
|
string |
"results"
|
the live-region word after the count |
search_placeholder
|
string |
"Search…"
|
trigger variant: placeholder for the search input inside the panel |
size
|
string |
"md"
|
control density - follows the field-size family
one of: "sm", "md", "lg"
|
value
|
any |
nil
|
current value - or list of values when multiple (overrides field) |
variant
|
string |
"input"
|
input is the searchable field; trigger is a select-like button whose panel carries the search input - the picker anatomy, and the data table's filter editor
one of: "input", "trigger"
|
:chip
slot
|
slot |
multiple mode: custom chip content rendered in place of the plain label - avatars, dots. `:let` receives the chosen normalized option map. The remove button stays appended. Client-side picks build plain-text chips optimistically until the LiveView patch re-renders the rich ones (server wins); server-rendered chips are left intact whenever they already match the selection. | |
:footer
slot
|
slot |
panel chrome rendered below the option list - counts, "manage" links. Lives OUTSIDE the listbox; pointer-interactive content works, keyboard focus stays with the options. | |
:header
slot
|
slot |
panel chrome rendered above the option list (below the trigger variant's search) - column captions, hints. Lives OUTSIDE the listbox, so keyboard navigation and filtering never touch it. | |
:option
slot
|
slot |
custom option content, rendered inside each panel option in place of the plain label - `:let` receives the normalized option map (`label`, `value`, `disabled`, and `meta`: everything else from the option's keyword opts). Filtering, chips and the trigger label keep using the plain label, so rich content never affects search or the closed state. | |
:selected
slot
|
slot |
trigger variant only: custom closed-state content rendered inside the trigger in place of the plain label/count - avatars, colored dots, "+N" summaries. `:let` receives the LIST of chosen normalized option maps (`label`, `value`, `meta`). Client-side picks show the plain optimistic text until the LiveView patch re-renders the slot - the server-wins reconciliation the trigger label already uses. The empty state always shows the placeholder. |
Migrating from the Tom Select combobox
Every advertised capability has a home. Swap the import, keep your handlers:
| Tom Select era | Now |
|---|---|
options, multiple, max_items,
placeholder
|
Same attrs, same shapes |
create
|
create (plus free_text for commit-without-row)
|
remote_options_event_name / remote_options_target
|
Identical - handlers and replies unchanged |
remove_button_title
|
remove_label
|
label, help_text on the component
|
<.field type="combobox">
|
tom_select_options / tom_select_plugins
|
Gone by design - first-class attrs and slots replace the plugin escape hatch |