We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Listbox
A single-select value picker with keyboard navigation, intelligent positioning, and Phoenix form integration. Use a listbox when users need to select one value from a predefined set of options.
Unlike Dropdown, which provides an action menu with role="menu",
Listbox
uses role="listbox"
and works like a native <select>. It includes smart positioning with
Floating UI
.
Quick Start
The most basic listbox requires three components: .listbox, .listbox_trigger, and
.listbox_options
with .listbox_option
elements.
<.listbox id="basic-listbox" name="favorite_fruit" value="Cherry">
<.listbox_trigger
id="basic-listbox-trigger"
class="w-56 inline-flex justify-between items-center rounded-lg bg-white border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<.listbox_value>Cherry</.listbox_value>
<svg
class="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</.listbox_trigger>
<.listbox_options
id="basic-listbox-options"
class="min-w-[var(--reference-width)] py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none"
>
<.listbox_option
:for={
{fruit, index} <- Enum.with_index(["Cherry", "Kiwi", "Grapefruit", "Orange", "Banana"])
}
id={"basic-listbox-option-#{index}"}
value={fruit}
class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 data-selected:font-semibold block w-full px-4 py-2 text-sm text-left"
>
{fruit}
</.listbox_option>
</.listbox_options>
</.listbox>
This example starts with "Cherry" selected through the value
attribute. Selecting another option updates the .listbox_value
content immediately.
Styling
Popup width
Width is controlled by CSS. --reference-width
tracks the width of the trigger, or the element selected by reference, while open.
Apply width classes to .listbox_options.
Use min-w-[var(--reference-width)] for a minimum width,
or w-[var(--reference-width)] for an exact match.
The Quick Start example uses min-w-[var(--reference-width)]
to keep the options at least as wide as the trigger.
Form Integration
.listbox
renders a hidden <input>
with the given name. Selecting an option updates the input and dispatches
input
and change
events. This makes a parent form's phx-change
event run as it does for a native form field.
Use .listbox_value
to render the current value or a placeholder in the trigger.
Selected fruit (from server state): none
defmodule DemoWeb.DemoLive.ListboxFormDemo do
@moduledoc false
use DemoWeb, :live_component
import Prima.Listbox
@fruits ["Cherry", "Kiwi", "Grapefruit", "Orange", "Banana"]
@impl true
def mount(socket) do
{:ok, assign(socket, fruits: @fruits, selected_fruit: nil)}
end
@impl true
def render(assigns) do
~H"""
<div>
<form phx-change="favorite_fruit_changed" phx-target={@myself}>
<.listbox id="demo-form-listbox" name="favorite_fruit" value={@selected_fruit}>
<.listbox_trigger
id="demo-form-listbox-trigger"
class="w-56 inline-flex justify-between items-center rounded-lg bg-white border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<.listbox_value>{@selected_fruit || "Select a fruit..."}</.listbox_value>
<svg
class="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</.listbox_trigger>
<.listbox_options
id="demo-form-listbox-options"
class="min-w-[var(--reference-width)] py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none"
>
<.listbox_option
:for={{fruit, index} <- Enum.with_index(@fruits)}
id={"demo-form-listbox-option-#{index}"}
value={fruit}
class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 data-selected:font-semibold block w-full px-4 py-2 text-sm text-left"
>
{fruit}
</.listbox_option>
</.listbox_options>
</.listbox>
</form>
<p class="mt-4 text-sm text-gray-600">
Selected fruit (from server state):
<span class="font-semibold text-gray-900">{@selected_fruit || "none"}</span>
</p>
</div>
"""
end
@impl true
def handle_event("favorite_fruit_changed", %{"favorite_fruit" => fruit}, socket) do
{:noreply, assign(socket, selected_fruit: fruit)}
end
end
The selected fruit below the trigger comes from server state. It confirms that the
phx-change
event completed while the trigger value updated immediately.
Disabled Options
Disable a listbox option with the disabled=true
attribute. Disabled options cannot receive focus or be selected.
<.listbox id="disabled-demo-listbox" name="plan">
<.listbox_trigger
id="disabled-demo-listbox-trigger"
class="w-56 inline-flex justify-between items-center rounded-lg bg-white border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<.listbox_value>Select a plan...</.listbox_value>
<svg
class="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</.listbox_trigger>
<.listbox_options
id="disabled-demo-listbox-options"
class="min-w-[var(--reference-width)] py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none"
>
<.listbox_option
id="disabled-demo-option-basic"
value="Basic"
class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block w-full px-4 py-2 text-sm text-left"
>
Basic
</.listbox_option>
<.listbox_option
id="disabled-demo-option-pro"
value="Pro"
class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block w-full px-4 py-2 text-sm text-left"
>
Pro
</.listbox_option>
<.listbox_option
id="disabled-demo-option-enterprise"
value="Enterprise"
disabled={true}
class="text-gray-400 data-disabled:opacity-50 block w-full px-4 py-2 text-sm text-left"
>
Enterprise (contact sales)
</.listbox_option>
</.listbox_options>
</.listbox>
Disabled items use aria-disabled="true"
and data-disabled="true"
attributes for accessibility and styling. Use the data-disabled:
variant to style disabled items differently, such as reducing opacity or changing text color.
Disabled Listbox
Disable the entire listbox with disabled=true
on .listbox. The hidden input is disabled in the initial HTML,
excluding its value from form submission. When the JavaScript hook mounts, it
disables the trigger button, preventing interaction and removing it from the tab
order. LiveView updates keep the state synchronized and close an open listbox
when it becomes disabled. Re-enabling the field preserves its selected value.
defmodule DemoWeb.DemoLive.DisabledListboxDemo do
@moduledoc false
use DemoWeb, :live_component
import Prima.Listbox
@impl true
def mount(socket) do
{:ok, assign(socket, disabled?: true, plan: nil, submitted: nil)}
end
@impl true
def render(assigns) do
~H"""
<form phx-change="plan_changed" phx-submit="submit" phx-target={@myself}>
<div class="flex items-center gap-4">
<.listbox id="disabled-listbox-example" name="plan" value={@plan} disabled={@disabled?}>
<.listbox_trigger
id="disabled-listbox-example-trigger"
class="w-56 inline-flex justify-between items-center rounded-lg bg-white border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 in-data-disabled:opacity-50 in-data-disabled:cursor-not-allowed in-data-disabled:hover:bg-white"
>
<.listbox_value>{@plan || "Select a plan..."}</.listbox_value>
<svg
class="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</.listbox_trigger>
<.listbox_options
id="disabled-listbox-example-options"
class="min-w-[var(--reference-width)] py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none"
>
<.listbox_option
id="disabled-listbox-example-option-basic"
value="Basic"
class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block w-full px-4 py-2 text-sm text-left"
>
Basic
</.listbox_option>
<.listbox_option
id="disabled-listbox-example-option-pro"
value="Pro"
class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 block w-full px-4 py-2 text-sm text-left"
>
Pro
</.listbox_option>
</.listbox_options>
</.listbox>
<button
id="disabled-listbox-example-toggle"
type="button"
phx-click="toggle_disabled"
phx-target={@myself}
class="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
{if @disabled?, do: "Enable", else: "Disable"}
</button>
<button
id="disabled-listbox-example-submit"
type="submit"
class="rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-gray-700"
>
Submit
</button>
</div>
<p class="mt-4 text-sm text-gray-600">
Submitted params:
<span id="disabled-listbox-example-submitted" class="font-mono text-gray-900">
{if @submitted, do: inspect(@submitted), else: "not submitted yet"}
</span>
</p>
</form>
"""
end
@impl true
def handle_event("plan_changed", %{"plan" => plan}, socket) do
{:noreply, assign(socket, plan: plan)}
end
def handle_event("toggle_disabled", _params, socket) do
{:noreply, update(socket, :disabled?, &(!&1))}
end
def handle_event("submit", params, socket) do
{:noreply, assign(socket, submitted: params)}
end
end
To style the disabled state, use the in-data-disabled: variant on the
trigger button. The regular data-disabled: does not work because Prima
is unable to copy the data-disabled="true" attribute over to the
trigger button in the server-rendered HTML.
Setting the Value Client-Side
Use Prima.Listbox.JS.set_value/3
to change the value without a server round-trip, for example to reset a field that
client-side code hides. It updates the hidden input, the displayed value and the
selection markers.
<div class="flex items-center gap-3">
<.listbox id="value-setter-listbox" name="role" value="editor">
<.listbox_trigger
id="value-setter-listbox-trigger"
class="w-56 inline-flex justify-between items-center rounded-lg bg-white border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<.listbox_value>Editor</.listbox_value>
<svg
class="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
d="M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z"
clip-rule="evenodd"
/>
</svg>
</.listbox_trigger>
<.listbox_options
id="value-setter-listbox-options"
class="py-1 rounded-md bg-white shadow-xs ring-1 ring-gray-300 focus:outline-none"
>
<.listbox_option
:for={{value, label} <- [{"viewer", "Viewer"}, {"editor", "Editor"}, {"admin", "Admin"}]}
id={"value-setter-option-#{value}"}
value={value}
display={label}
class="text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 data-selected:font-semibold block w-full px-4 py-2 text-sm text-left"
>
{label}
</.listbox_option>
</.listbox_options>
</.listbox>
<button
type="button"
phx-click={Prima.Listbox.JS.set_value("value-setter-listbox", "viewer", notify: false)}
class="rounded-lg bg-white border border-gray-300 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Reset to Viewer
</button>
</div>
Like setting .value
on a native <select>, it dispatches no events by default, so
phx-change
doesn't fire and the server keeps its old value. Pass notify: true
to dispatch input
and change
like a user selection. Values that match no option are ignored. From plain JavaScript,
dispatch a prima:listbox:set-value
event on the listbox element with the value in its detail.
Keyboard Interaction
The listbox provides keyboard navigation that follows standard ARIA patterns. Use the arrow keys to move between options and Enter or Space to select an option.
| Key | Description |
|---|---|
| When the trigger button is focused: | |
| Enter / Space | Opens the listbox and focuses the current selection (or the first option, if none) |
| ↓ | Opens the listbox and focuses the first non-disabled option |
| ↑ | Opens the listbox and focuses the last non-disabled option |
| When the listbox is open: | |
| Esc | Closes the listbox without changing the selection, and returns focus to the trigger |
| ↑ / ↓ | Focuses the previous/next non-disabled option (wraps around) |
| Home / End | Focuses the first/last non-disabled option |
| Enter / Space | Selects the focused option, updates the displayed value, and closes the listbox |
| A-Z / 0-9 | Focuses the first option that starts with the typed character. Repeated presses cycle through matching options. |