Components Combobox

Petal Pro is the full SaaS app this is built for

Auth, billing, admin, and Claude Code integration included. One purchase, unlimited projects.

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

This component replaces the Tom Select-based Pro combobox in Petal Components 4.12 - same remote-search contract, zero JS dependencies, and a lot more surface: a trigger variant, rich option/chip/label slots, free text and create modes, and full <.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.

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

Phoenix LiveView
heex
<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.

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

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

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

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

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

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

Amelia Ward Jonah Reyes
heex
<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.

heex
<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:

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

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