Components
listbox
A scrollable, always-visible single- or multi-select list built on role="listbox" with role="option" children. Full APG keyboard contract; a hidden input mirrors the selection so it submits like a normal field.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/listbox.json2. Use it
import { Listbox, ListboxOption } from "@/components/ui/listbox"
<Listbox ariaLabel="Favourite element" name="element">
<ListboxOption value="H" selected>Hydrogen</ListboxOption>
<ListboxOption value="He">Helium</ListboxOption>
<ListboxOption value="Li">Lithium</ListboxOption>
</Listbox>
// Multi-select — Space toggles, Shift/Ctrl extend the range.
<Listbox ariaLabel="Toppings" name="toppings" multiple>
<ListboxOption value="cheese">Cheese</ListboxOption>
<ListboxOption value="olives" selected>Olives</ListboxOption>
</Listbox>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Listbox — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn/ui builds its rich Select on a Radix popover with role="listbox"
// options. We split that into two components: `Select` is the truly-native
// dropdown (<select>), and this `Listbox` is the always-visible, scrollable,
// single/multi-select widget — the APG Listbox pattern. We mirror shadcn's
// anatomy (a container + option children) but translate it to a real
// role="listbox" / role="option" tree, not a Radix portal.
// Anatomy reference (intent only): repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/select.tsx
//
// Accessibility contract follows the WAI-ARIA APG Listbox pattern:
// repos/aria-practices/content/patterns/listbox/listbox-pattern.html
// (roles/states: listbox, option, aria-selected, aria-multiselectable,
// aria-orientation; keyboard: Up/Down/Home/End, Space/Enter, type-ahead,
// Shift/Ctrl range + Ctrl+A for multi-select)
// repos/aria-practices/content/patterns/listbox/examples/listbox-scrollable.html
// (the scrollable single-select example whose markup we mirror: a <ul>
// with role="listbox" holding <li role="option"> children)
//
// Focus management — roving tabindex (NOT aria-activedescendant). The APG
// permits either; the rest of this library (Toolbar, Tabs, Menu) uses a
// roving tabindex, so we keep DOM focus on the options for consistency. The
// container <ul> is tabindex="-1"; exactly one <li role="option"> carries
// tabindex="0" (the first selected option, else the first option) and the
// rest tabindex="-1". An inline boot <script> sets that before paint (no
// flicker); public/site.js (keyed on data-slot="listbox") owns the live
// keyboard + selection contract and keeps the hidden form input in sync.
// Roving tabindex rationale: repos/aria-practices/content/practices/keyboard-interface/keyboard-interface-practice.html
//
// Selection state uses aria-selected (APG's recommended convention for
// single-select; we keep it for multi-select too so the markup is uniform).
// The container sets aria-multiselectable="true" when multiple.
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/listbox_role/index.md
// repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-selected/index.md
//
// Form association: the styled listbox is a custom widget, so it does not
// submit on its own like a control. We render a sibling <input type="hidden">
// whose value mirrors the selection (a single value, or a comma-joined list
// when multiple) and keep it current from the boot script + site.js. For a
// zero-JS alternative, a native <select multiple> is the platform's listbox
// (it submits each selected <option> automatically) — use it when you don't
// need custom option rendering. See repos/mdn/files/en-us/web/html/reference/elements/select/
export type ListboxOrientation = "horizontal" | "vertical"
const containerBase =
"max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-md border bg-background p-1 text-sm shadow-xs outline-none " +
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 " +
"data-[orientation=horizontal]:flex data-[orientation=horizontal]:max-h-none data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden"
const optionBase =
"relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none " +
"hover:bg-accent hover:text-accent-foreground " +
"focus-visible:bg-accent focus-visible:text-accent-foreground " +
// Selected option gets the primary fill; matches how shadcn marks a chosen item.
"aria-selected:bg-primary aria-selected:text-primary-foreground " +
"aria-disabled:pointer-events-none aria-disabled:opacity-50 " +
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
export function listboxClasses(opts?: { class?: ClassValue }): string {
return cn(containerBase, opts?.class)
}
export function listboxOptionClasses(opts?: { class?: ClassValue }): string {
return cn(optionBase, opts?.class)
}
type ListboxProps = PropsWithChildren<{
// Required when there's no visible label so AT can name the listbox.
// APG: a standalone listbox must be labelled via aria-label or aria-labelledby.
ariaLabel?: string
ariaLabelledby?: string
// Allow choosing more than one option. Sets aria-multiselectable="true".
multiple?: boolean
// Disable the whole widget (sets aria-disabled; options become inert).
disabled?: boolean
// Group-level requirement: "one option must be chosen". Sets aria-required
// on the container. The styled listbox submits via a hidden input, so the
// native `required` attribute cannot apply — aria-required is the only way
// to convey it. Mirrors RadioGroup's `required` -> aria-required.
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/listbox_role/index.md (aria-required under States and Properties)
required?: boolean
// Mark the widget invalid for the WCAG error-identification pattern (e.g. a
// required listbox submitted empty, or server validation over htmx). Pair
// with ariaErrormessage pointing at a visible error element's id.
// repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-invalid/index.md (listbox in Associated roles)
ariaInvalid?: boolean
// Id of a visible element holding the error message. Pair with ariaInvalid.
// repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-errormessage/index.md (listbox in Associated roles)
ariaErrormessage?: string
// Locked-but-operable: the user cannot change the selection but the listbox
// stays focusable/navigable. Distinct from `disabled` (which makes options
// inert). Sets aria-readonly; mirrors Checkbox's `ariaReadonly`.
// repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-readonly/index.md (listbox in Associated roles)
ariaReadonly?: boolean
// Layout axis. Default vertical; "horizontal" sets aria-orientation and
// flips the arrow-key axis (Left/Right) in site.js.
orientation?: ListboxOrientation
// Name of the hidden form input that mirrors the selection. Omit to skip
// the hidden input entirely (e.g. when you drive selection over htmx).
name?: string
id?: string
class?: ClassValue
// htmx + arbitrary attributes ride onto the listbox container. Typical:
// hx-post="/save" hx-trigger="listbox:change" (fired by site.js).
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
export function Listbox(props: ListboxProps) {
const {
ariaLabel,
ariaLabelledby,
multiple,
disabled,
required,
ariaInvalid,
ariaErrormessage,
ariaReadonly,
orientation = "vertical",
name,
class: className,
children,
...rest
} = props as any
// Boot script: establish the roving tabindex before paint (single tab stop),
// and seed the hidden form input from the initial aria-selected options.
// The first selected option (else the first option) gets tabindex="0".
const boot = `(function(el){
var opts = el.querySelectorAll('[role="option"]');
var sel = el.querySelector('[role="option"][aria-selected="true"]:not([aria-disabled="true"])');
var active = sel || el.querySelector('[role="option"]:not([aria-disabled="true"])');
opts.forEach(function(o){ o.setAttribute('tabindex', o === active ? '0' : '-1'); });
var hidden = el.parentNode && el.parentNode.querySelector('[data-listbox-value]');
if (hidden) {
var vals = [];
el.querySelectorAll('[role="option"][aria-selected="true"]').forEach(function(o){
vals.push(o.getAttribute('data-value') || (o.textContent || '').trim());
});
hidden.value = vals.join(',');
}
el.setAttribute('data-listbox-ready','true');
})(document.currentScript.parentNode.querySelector('[data-slot="listbox"]'));`
return (
<span class="relative inline-flex w-full flex-col">
<ul
role="listbox"
data-slot="listbox"
data-orientation={orientation}
aria-orientation={orientation}
aria-multiselectable={multiple ? "true" : undefined}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-disabled={disabled ? "true" : undefined}
// Group-level "one must be chosen" requirement (listbox supports aria-required).
aria-required={required ? "true" : undefined}
aria-invalid={ariaInvalid === undefined ? undefined : String(ariaInvalid)}
aria-errormessage={ariaErrormessage}
// Locked-but-operable, distinct from aria-disabled.
aria-readonly={ariaReadonly ? "true" : undefined}
// The container is not in the tab order; focus lands on the options
// (roving tabindex). tabindex="-1" lets us still programmatically
// focus the list itself if needed without adding a tab stop.
tabindex={-1}
class={listboxClasses({ class: className })}
{...rest}
>
{children}
</ul>
{/* Hidden form value — mirrors the selection so the listbox submits
like a normal field. site.js keeps it in sync on every change. */}
{name ? (
<input type="hidden" name={name} data-listbox-value="" />
) : null}
<script
// biome-ignore lint/security/noDangerouslySetInnerHtml: SSR boot
dangerouslySetInnerHTML={{ __html: boot }}
/>
</span>
)
}
type ListboxOptionProps = PropsWithChildren<{
// The value submitted via the hidden input when selected. Defaults to the
// option's text content when omitted.
value?: string
// Pre-select this option. In a single-select listbox only one should be set.
selected?: boolean
// Keep the option in the list (and announced by AT) but unselectable.
disabled?: boolean
id?: string
class?: ClassValue
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
export function ListboxOption(props: ListboxOptionProps) {
const {
value,
selected,
disabled,
class: className,
children,
...rest
} = props as any
return (
<li
role="option"
data-slot="listbox-option"
data-value={value}
// Every selectable option carries aria-selected (true/false) per APG;
// disabled options stay in the tree but are aria-disabled.
aria-selected={disabled ? undefined : selected ? "true" : "false"}
aria-disabled={disabled ? "true" : undefined}
// tabindex is assigned by the boot script / site.js (roving tabindex).
class={listboxOptionClasses({ class: className })}
{...rest}
>
{children}
</li>
)
}
// A labelled cluster of options. role="group" lets AT announce the group
// name without adding a tab stop; the options inside still participate in
// the listbox's single roving tabindex.
// repos/aria-practices/content/patterns/listbox/examples/listbox-grouped.html
type ListboxGroupProps = PropsWithChildren<{
// Visible/accessible name for the group. APG requires grouped options to
// have an accessible name via aria-label or aria-labelledby.
label: string
class?: ClassValue
}>
export function ListboxGroup(props: ListboxGroupProps) {
const { label, class: className, children } = props
return (
<li role="presentation" data-slot="listbox-group-wrapper" class={cn("py-1", className)}>
<span
aria-hidden="true"
class="px-2 py-1 text-xs font-medium text-muted-foreground"
>
{label}
</span>
<ul role="group" data-slot="listbox-group" aria-label={label} class="contents">
{children}
</ul>
</li>
)
}
1. Save the file
Copy listbox.html into templates/components/.
2. Use it
{% from "components/listbox.html" import listbox_open, listbox_close, listbox_option %}
{{ listbox_open(aria_label="Favourite element", name="element") }}
{{ listbox_option("Hydrogen", value="H", selected=true) }}
{{ listbox_option("Helium", value="He") }}
{{ listbox_option("Lithium", value="Li") }}
{{ listbox_close(name="element") }}View source
{# Listbox macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/listbox.tsx. Renders the APG Listbox pattern: a
<ul role="listbox"> with <li role="option"> children, a sibling hidden
<input> for form submission, and a boot <script> that sets the roving
tabindex (single tab stop) + seeds the hidden value on first paint.
public/site.js (keyed on data-slot="listbox") owns the live keyboard +
selection contract. Accessibility contract:
repos/aria-practices/content/patterns/listbox/listbox-pattern.html
Usage:
{% from "components/listbox.html" import listbox_open, listbox_close,
listbox_option, listbox_group_open, listbox_group_close %}
{{ listbox_open(aria_label="Favourite element", name="element") }}
{{ listbox_option("Hydrogen", value="H", selected=true) }}
{{ listbox_option("Helium", value="He") }}
{{ listbox_option("Lithium", value="Li") }}
{{ listbox_close(name="element") }} #}
{% macro listbox_open(aria_label=none, aria_labelledby=none, multiple=false, disabled=false, required=false, aria_invalid=none, aria_errormessage=none, aria_readonly=false, orientation="vertical", name=none, extra_class="", attrs={}) -%}
<span class="relative inline-flex w-full flex-col">
<ul role="listbox"
data-slot="listbox"
data-orientation="{{ orientation }}"
aria-orientation="{{ orientation }}"
{%- if multiple %} aria-multiselectable="true"{% endif %}
{%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
{%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
{%- if disabled %} aria-disabled="true"{% endif %}
{#- Group-level "one must be chosen" requirement (listbox supports aria-required). #}
{%- if required %} aria-required="true"{% endif %}
{#- WCAG error-identification: aria-invalid + aria-errormessage point at a visible error. #}
{%- if aria_invalid is not none %} aria-invalid="{{ 'true' if aria_invalid else 'false' }}"{% endif %}
{%- if aria_errormessage %} aria-errormessage="{{ aria_errormessage }}"{% endif %}
{#- Locked-but-operable, distinct from aria-disabled. #}
{%- if aria_readonly %} aria-readonly="true"{% endif %}
tabindex="-1"
{%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
class="max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-md border bg-background p-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-[orientation=horizontal]:flex data-[orientation=horizontal]:max-h-none data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden {{ extra_class }}">
{%- endmacro %}
{% macro listbox_close(name=none) -%}
</ul>
{%- if name %}
<input type="hidden" name="{{ name }}" data-listbox-value="">
{%- endif %}
<script>(function(el){
var opts = el.querySelectorAll('[role="option"]');
var sel = el.querySelector('[role="option"][aria-selected="true"]:not([aria-disabled="true"])');
var active = sel || el.querySelector('[role="option"]:not([aria-disabled="true"])');
opts.forEach(function(o){ o.setAttribute('tabindex', o === active ? '0' : '-1'); });
var hidden = el.parentNode && el.parentNode.querySelector('[data-listbox-value]');
if (hidden) {
var vals = [];
el.querySelectorAll('[role="option"][aria-selected="true"]').forEach(function(o){
vals.push(o.getAttribute('data-value') || (o.textContent || '').trim());
});
hidden.value = vals.join(',');
}
el.setAttribute('data-listbox-ready','true');
})(document.currentScript.parentNode.querySelector('[data-slot="listbox"]'));</script>
</span>
{%- endmacro %}
{% macro listbox_option(label, value=none, selected=false, disabled=false, id=none, extra_class="", attrs={}) -%}
<li role="option"
data-slot="listbox-option"
{%- if value is not none %} data-value="{{ value }}"{% endif %}
{%- if disabled %} aria-disabled="true"{% else %} aria-selected="{{ 'true' if selected else 'false' }}"{% endif %}
{%- if id %} id="{{ id }}"{% endif %}
{%- for k, v in attrs.items() %} {{ k|replace('_','-') }}="{{ v }}"{% endfor %}
class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 {{ extra_class }}">{{ label }}</li>
{%- endmacro %}
{# A labelled cluster of options. role="group" carries the accessible name;
options inside still participate in the listbox's roving tabindex. #}
{% macro listbox_group_open(label, extra_class="") -%}
<li role="presentation" data-slot="listbox-group-wrapper" class="py-1 {{ extra_class }}">
<span aria-hidden="true" class="px-2 py-1 text-xs font-medium text-muted-foreground">{{ label }}</span>
<ul role="group" data-slot="listbox-group" aria-label="{{ label }}" class="contents">
{%- endmacro %}
{% macro listbox_group_close() %}</ul></li>{% endmacro %}
1. Save the file
Add listbox.tmpl alongside your other templates.
2. Use it
{{- $opts := htmlSafe (printf "%s%s%s"
(renderOption "Hydrogen" "H" true)
(renderOption "Helium" "He" false)
(renderOption "Lithium" "Li" false)) -}}
{{template "listbox" (dict "AriaLabel" "Favourite element" "Name" "element" "Body" $opts)}}
{{/* Compose options with the "listbox_option" template, then pass as .Body. */}}View source
{{/*
Listbox template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/listbox.tsx. Named templates:
- "listbox" — wrapper (<span> + <ul role="listbox">) + hidden
input + boot script. Pass .Body (template.HTML)
with the composed options/groups.
- "listbox_option" — one <li role="option">
- "listbox_group" — a labelled role="group" cluster (pass .Body)
The boot script sets the roving tabindex (single tab stop) + seeds the
hidden form value on first paint; public/site.js (keyed on
data-slot="listbox") owns the live keyboard + selection contract.
Accessibility contract:
repos/aria-practices/content/patterns/listbox/listbox-pattern.html
Hand-compose the inner HTML (options/groups) and pass it as .Body
(template.HTML / htmlSafe). Set .Name to render the hidden form input.
*/}}
{{define "listbox"}}
{{- $orientation := or .Orientation "vertical" -}}
<span class="relative inline-flex w-full flex-col">
<ul role="listbox"
data-slot="listbox"
data-orientation="{{$orientation}}"
aria-orientation="{{$orientation}}"
{{- if .Multiple}} aria-multiselectable="true"{{end}}
{{- if .AriaLabel}} aria-label="{{.AriaLabel}}"{{end}}
{{- if .AriaLabelledby}} aria-labelledby="{{.AriaLabelledby}}"{{end}}
{{- if .Disabled}} aria-disabled="true"{{end}}
{{- /* Group-level "one must be chosen" requirement (listbox supports aria-required). */}}
{{- if .Required}} aria-required="true"{{end}}
{{- /* WCAG error-identification: aria-invalid + aria-errormessage point at a visible error. */}}
{{- if .AriaInvalid}} aria-invalid="true"{{end}}
{{- if .AriaErrormessage}} aria-errormessage="{{.AriaErrormessage}}"{{end}}
{{- /* Locked-but-operable, distinct from aria-disabled. */}}
{{- if .AriaReadonly}} aria-readonly="true"{{end}}
tabindex="-1"
class="max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-md border bg-background p-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-[orientation=horizontal]:flex data-[orientation=horizontal]:max-h-none data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden">
{{.Body}}
</ul>
{{- if .Name}}
<input type="hidden" name="{{.Name}}" data-listbox-value="">
{{- end}}
<script>(function(el){
var opts = el.querySelectorAll('[role="option"]');
var sel = el.querySelector('[role="option"][aria-selected="true"]:not([aria-disabled="true"])');
var active = sel || el.querySelector('[role="option"]:not([aria-disabled="true"])');
opts.forEach(function(o){ o.setAttribute('tabindex', o === active ? '0' : '-1'); });
var hidden = el.parentNode && el.parentNode.querySelector('[data-listbox-value]');
if (hidden) {
var vals = [];
el.querySelectorAll('[role="option"][aria-selected="true"]').forEach(function(o){
vals.push(o.getAttribute('data-value') || (o.textContent || '').trim());
});
hidden.value = vals.join(',');
}
el.setAttribute('data-listbox-ready','true');
})(document.currentScript.parentNode.querySelector('[data-slot="listbox"]'));</script>
</span>
{{end}}
{{define "listbox_option"}}
<li role="option"
data-slot="listbox-option"
{{- if .Value}} data-value="{{.Value}}"{{end}}
{{- if .Disabled}} aria-disabled="true"{{else}} aria-selected="{{if .Selected}}true{{else}}false{{end}}"{{end}}
{{- if .ID}} id="{{.ID}}"{{end}}
class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">{{.Label}}</li>
{{end}}
{{define "listbox_group"}}
<li role="presentation" data-slot="listbox-group-wrapper" class="py-1">
<span aria-hidden="true" class="px-2 py-1 text-xs font-medium text-muted-foreground">{{.Label}}</span>
<ul role="group" data-slot="listbox-group" aria-label="{{.Label}}" class="contents">
{{.Body}}
</ul>
</li>
{{end}}
1. Save the file
Drop listbox.ex into lib/my_app_web/components/.
2. Use it
<.listbox aria-label="Favourite element" name="element">
<.listbox_option value="H" selected>Hydrogen</.listbox_option>
<.listbox_option value="He">Helium</.listbox_option>
<.listbox_option value="Li">Lithium</.listbox_option>
</.listbox>View source
defmodule ShadcnHtmx.Components.Listbox do
@moduledoc """
Listbox — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
Mirrors registry/ui/listbox.tsx. Function components: `listbox`,
`listbox_option`, `listbox_group`.
Renders the APG Listbox pattern: a `<ul role="listbox">` with
`<li role="option">` children, a sibling hidden `<input>` for form
submission, and a boot `<script>` that sets the roving tabindex (single
tab stop) + seeds the hidden value on first paint. public/site.js (keyed
on data-slot="listbox") owns the live keyboard + selection contract.
Accessibility contract:
repos/aria-practices/content/patterns/listbox/listbox-pattern.html
## Examples
<.listbox aria-label="Favourite element" name="element">
<.listbox_option value="H" selected>Hydrogen</.listbox_option>
<.listbox_option value="He">Helium</.listbox_option>
<.listbox_option value="Li">Lithium</.listbox_option>
</.listbox>
"""
use Phoenix.Component
attr :orientation, :string, default: "vertical", values: ~w(horizontal vertical)
attr :multiple, :boolean, default: false
attr :disabled, :boolean, default: false
# Group-level "one must be chosen" requirement (listbox supports aria-required).
# repos/mdn/files/en-us/web/accessibility/aria/reference/roles/listbox_role/index.md
attr :required, :boolean, default: false
# WCAG error-identification: aria-invalid + aria-errormessage point at a visible error.
# repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-invalid/index.md
attr :"aria-invalid", :boolean, default: nil
attr :"aria-errormessage", :string, default: nil
# Locked-but-operable, distinct from aria-disabled.
# repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-readonly/index.md
attr :"aria-readonly", :boolean, default: false
attr :name, :string, default: nil
attr :"aria-label", :string, default: nil
attr :"aria-labelledby", :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def listbox(assigns) do
~H"""
<span class="relative inline-flex w-full flex-col">
<ul
role="listbox"
data-slot="listbox"
data-orientation={@orientation}
aria-orientation={@orientation}
aria-multiselectable={@multiple && "true"}
aria-label={assigns[:"aria-label"]}
aria-labelledby={assigns[:"aria-labelledby"]}
aria-disabled={@disabled && "true"}
aria-required={@required && "true"}
aria-invalid={if assigns[:"aria-invalid"] == nil, do: nil, else: to_string(assigns[:"aria-invalid"])}
aria-errormessage={assigns[:"aria-errormessage"]}
aria-readonly={assigns[:"aria-readonly"] && "true"}
tabindex="-1"
class={[
"max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-md border bg-background p-1 text-sm shadow-xs outline-none",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"aria-disabled:cursor-not-allowed aria-disabled:opacity-50",
"data-[orientation=horizontal]:flex data-[orientation=horizontal]:max-h-none data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden",
@class
]}
{@rest}
>
{render_slot(@inner_block)}
</ul>
<input :if={@name} type="hidden" name={@name} data-listbox-value="" />
<script>{Phoenix.HTML.raw(~s"""
(function(el){
var opts = el.querySelectorAll('[role="option"]');
var sel = el.querySelector('[role="option"][aria-selected="true"]:not([aria-disabled="true"])');
var active = sel || el.querySelector('[role="option"]:not([aria-disabled="true"])');
opts.forEach(function(o){ o.setAttribute('tabindex', o === active ? '0' : '-1'); });
var hidden = el.parentNode && el.parentNode.querySelector('[data-listbox-value]');
if (hidden) {
var vals = [];
el.querySelectorAll('[role="option"][aria-selected="true"]').forEach(function(o){
vals.push(o.getAttribute('data-value') || (o.textContent || '').trim());
});
hidden.value = vals.join(',');
}
el.setAttribute('data-listbox-ready','true');
})(document.currentScript.parentNode.querySelector('[data-slot="listbox"]'));
""")}</script>
</span>
"""
end
attr :value, :string, default: nil
attr :selected, :boolean, default: false
attr :disabled, :boolean, default: false
attr :id, :string, default: nil
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def listbox_option(assigns) do
~H"""
<li
role="option"
data-slot="listbox-option"
data-value={@value}
aria-selected={!@disabled && (if @selected, do: "true", else: "false")}
aria-disabled={@disabled && "true"}
id={@id}
class={[
"relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:bg-accent focus-visible:text-accent-foreground",
"aria-selected:bg-primary aria-selected:text-primary-foreground",
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
@class
]}
{@rest}
>
{render_slot(@inner_block)}
</li>
"""
end
attr :label, :string, required: true
attr :class, :string, default: nil
slot :inner_block, required: true
def listbox_group(assigns) do
~H"""
<li role="presentation" data-slot="listbox-group-wrapper" class={["py-1", @class]}>
<span aria-hidden="true" class="px-2 py-1 text-xs font-medium text-muted-foreground">
{@label}
</span>
<ul role="group" data-slot="listbox-group" aria-label={@label} class="contents">
{render_slot(@inner_block)}
</ul>
</li>
"""
end
end
1. Save the file
Paste the markup; it relies only on the theme tokens in styles.css. The shared keyboard contract lives in public/site.js.
2. Use it
<ul role="listbox" data-slot="listbox" aria-label="Favourite element"
data-orientation="vertical" tabindex="-1" class="…">
<li role="option" data-slot="listbox-option" data-value="H" aria-selected="true" class="…">Hydrogen</li>
<li role="option" data-slot="listbox-option" data-value="He" aria-selected="false" class="…">Helium</li>
</ul>
<input type="hidden" name="element" data-listbox-value="">
<!-- inline boot <script> sets the roving tabindex + seeds the hidden value -->View source
<!--
shadcn-htmx — raw HTML listbox snippet.
Mirrors registry/ui/listbox.tsx. The APG Listbox pattern: a
<ul role="listbox"> with <li role="option"> children. The inline <script>
right after the wrapper sets the roving tabindex (single tab stop) and
seeds the hidden form value on first paint (no flicker). The live keyboard
+ selection contract (Up/Down/Home/End, Space/Enter, type-ahead, multi-
select range) needs the wiring in public/site.js, keyed on
data-slot="listbox".
Accessibility contract:
repos/aria-practices/content/patterns/listbox/listbox-pattern.html
Optional state attributes on the <ul role="listbox"> (add as needed):
aria-required="true" group-level "one option must be chosen"
(listbox supports aria-required)
aria-invalid="true" mark invalid for the WCAG error-id pattern
aria-errormessage="errId" id of the visible error message element
aria-readonly="true" locked-but-operable (focusable/navigable,
selection cannot change) — distinct from
aria-disabled which makes options inert
See repos/mdn/files/en-us/web/accessibility/aria/reference/roles/listbox_role/index.md
Required CSS theme variables: --background, --foreground, --primary,
--primary-foreground, --accent, --accent-foreground, --muted-foreground,
--border, --ring. See app/styles/input.css.
Native fallback (zero JS): a <select multiple> IS the platform's listbox —
it submits each selected <option> on its own. Use it when you don't need
custom option rendering. The styled widget below is the APG listbox.
-->
<span class="relative inline-flex w-full flex-col">
<ul id="fav-element"
role="listbox"
data-slot="listbox"
data-orientation="vertical"
aria-orientation="vertical"
aria-label="Favourite element"
tabindex="-1"
class="max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-md border bg-background p-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-[orientation=horizontal]:flex data-[orientation=horizontal]:max-h-none data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden">
<li role="option" data-slot="listbox-option" data-value="H" aria-selected="true"
class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Hydrogen</li>
<li role="option" data-slot="listbox-option" data-value="He" aria-selected="false"
class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Helium</li>
<li role="option" data-slot="listbox-option" data-value="Li" aria-selected="false"
class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Lithium</li>
<li role="option" data-slot="listbox-option" data-value="Be" aria-disabled="true"
class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Beryllium</li>
</ul>
<!-- Hidden form value — site.js keeps it in sync with the selection. -->
<input type="hidden" name="element" data-listbox-value="">
<script>
(function (el) {
var opts = el.querySelectorAll('[role="option"]')
var sel = el.querySelector('[role="option"][aria-selected="true"]:not([aria-disabled="true"])')
var active = sel || el.querySelector('[role="option"]:not([aria-disabled="true"])')
opts.forEach(function (o) { o.setAttribute('tabindex', o === active ? '0' : '-1') })
var hidden = el.parentNode && el.parentNode.querySelector('[data-listbox-value]')
if (hidden) {
var vals = []
el.querySelectorAll('[role="option"][aria-selected="true"]').forEach(function (o) {
vals.push(o.getAttribute('data-value') || (o.textContent || '').trim())
})
hidden.value = vals.join(',')
}
el.setAttribute('data-listbox-ready', 'true')
})(document.currentScript.parentNode.querySelector('[data-slot="listbox"]'))
</script>
</span>
Examples
Single-select
One option at a time. Up/Down move and select the focused option; Home/End jump to the ends; type-ahead matches by first letter.
A vertical role="listbox" with role="option" children. Focus moves with a roving tabindex — exactly one option is in the tab order at a time — and the selected option carries aria-selected="true". The hidden <input> mirrors the value so the widget submits like a normal field.
- Hydrogen
- Helium
- Lithium
- Beryllium
- Boron
- Carbon
- Nitrogen
- Oxygen
- Fluorine
- Neon
<Listbox ariaLabel="Favourite element" name="element">
<ListboxOption value="H" selected>Hydrogen</ListboxOption>
<ListboxOption value="He">Helium</ListboxOption>
<ListboxOption value="Li">Lithium</ListboxOption>
</Listbox>{{ listbox_open(aria_label="Favourite element", name="element") }}
{{ listbox_option("Hydrogen", value="H", selected=true) }}
{{ listbox_option("Helium", value="He") }}
{{ listbox_close(name="element") }}{{template "listbox" (dict "AriaLabel" "Favourite element" "Name" "element" "Body" $opts)}}<.listbox aria-label="Favourite element" name="element">
<.listbox_option value="H" selected>Hydrogen</.listbox_option>
<.listbox_option value="He">Helium</.listbox_option>
</.listbox><div class="grid w-full max-w-xs gap-2">
<label id="lb-elem-label" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Favourite element</label>
<span class="relative inline-flex w-full flex-col">
<ul role="listbox" data-slot="listbox" data-orientation="vertical" aria-orientation="vertical" aria-labelledby="lb-elem-label" tabindex="-1" class="max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-md border bg-background p-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-[orientation=horizontal]:flex data-[orientation=horizontal]:max-h-none data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden">
<li role="option" data-slot="listbox-option" data-value="H" aria-selected="false" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Hydrogen</li>
<li role="option" data-slot="listbox-option" data-value="He" aria-selected="false" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Helium</li>
<li role="option" data-slot="listbox-option" data-value="Li" aria-selected="false" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Lithium</li>
<li role="option" data-slot="listbox-option" data-value="Be" aria-selected="false" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Beryllium</li>
<li role="option" data-slot="listbox-option" data-value="B" aria-selected="false" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Boron</li>
<li role="option" data-slot="listbox-option" data-value="C" aria-selected="true" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Carbon</li>
<li role="option" data-slot="listbox-option" data-value="N" aria-selected="false" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Nitrogen</li>
<li role="option" data-slot="listbox-option" data-value="O" aria-selected="false" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Oxygen</li>
<li role="option" data-slot="listbox-option" data-value="F" aria-selected="false" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Fluorine</li>
<li role="option" data-slot="listbox-option" data-value="Ne" aria-selected="false" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Neon</li>
</ul>
<input type="hidden" name="element" data-listbox-value=""/>
<script>
(function(el){
var opts = el.querySelectorAll('[role="option"]');
var sel = el.querySelector('[role="option"][aria-selected="true"]:not([aria-disabled="true"])');
var active = sel || el.querySelector('[role="option"]:not([aria-disabled="true"])');
opts.forEach(function(o){ o.setAttribute('tabindex', o === active ? '0' : '-1'); });
var hidden = el.parentNode && el.parentNode.querySelector('[data-listbox-value]');
if (hidden) {
var vals = [];
el.querySelectorAll('[role="option"][aria-selected="true"]').forEach(function(o){
vals.push(o.getAttribute('data-value') || (o.textContent || '').trim());
});
hidden.value = vals.join(',');
}
el.setAttribute('data-listbox-ready','true');
})(document.currentScript.parentNode.querySelector('[data-slot="listbox"]'));
</script>
</span>
</div>Multi-select
Set multiple, and the container gets aria-multiselectable. Space toggles the focused option; Shift+Arrow / Shift+Click extend a range; Ctrl/Cmd+A selects all.
With multiple, the listbox sets aria-multiselectable="true" and follows the APG recommended model — no modifier needed to toggle. The hidden input collects every selected value as a comma-joined string. Disabled options stay announced by AT but can't be toggled.
- Cheese
- Mushroom
- Olives
- Pineapple (out of stock)
- Onion
<Listbox ariaLabel="Toppings" name="toppings" multiple>
<ListboxOption value="cheese" selected>Cheese</ListboxOption>
<ListboxOption value="mushroom">Mushroom</ListboxOption>
<ListboxOption value="pineapple" disabled>Pineapple</ListboxOption>
</Listbox>{{ listbox_open(aria_label="Toppings", name="toppings", multiple=true) }}
{{ listbox_option("Cheese", value="cheese", selected=true) }}
{{ listbox_option("Pineapple", value="pineapple", disabled=true) }}
{{ listbox_close(name="toppings") }}{{template "listbox" (dict "AriaLabel" "Toppings" "Name" "toppings" "Multiple" true "Body" $opts)}}<.listbox aria-label="Toppings" name="toppings" multiple>
<.listbox_option value="cheese" selected>Cheese</.listbox_option>
<.listbox_option value="pineapple" disabled>Pineapple</.listbox_option>
</.listbox><div class="grid w-full max-w-xs gap-2">
<label id="lb-top-label" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Pizza toppings</label>
<span class="relative inline-flex w-full flex-col">
<ul role="listbox" data-slot="listbox" data-orientation="vertical" aria-orientation="vertical" aria-multiselectable="true" aria-labelledby="lb-top-label" tabindex="-1" class="max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-md border bg-background p-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-[orientation=horizontal]:flex data-[orientation=horizontal]:max-h-none data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden">
<li role="option" data-slot="listbox-option" data-value="cheese" aria-selected="true" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Cheese</li>
<li role="option" data-slot="listbox-option" data-value="mushroom" aria-selected="false" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Mushroom</li>
<li role="option" data-slot="listbox-option" data-value="olives" aria-selected="true" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Olives</li>
<li role="option" data-slot="listbox-option" data-value="pineapple" aria-disabled="true" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Pineapple (out of stock)</li>
<li role="option" data-slot="listbox-option" data-value="onion" aria-selected="false" class="relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">Onion</li>
</ul>
<input type="hidden" name="toppings" data-listbox-value=""/>
<script>
(function(el){
var opts = el.querySelectorAll('[role="option"]');
var sel = el.querySelector('[role="option"][aria-selected="true"]:not([aria-disabled="true"])');
var active = sel || el.querySelector('[role="option"]:not([aria-disabled="true"])');
opts.forEach(function(o){ o.setAttribute('tabindex', o === active ? '0' : '-1'); });
var hidden = el.parentNode && el.parentNode.querySelector('[data-listbox-value]');
if (hidden) {
var vals = [];
el.querySelectorAll('[role="option"][aria-selected="true"]').forEach(function(o){
vals.push(o.getAttribute('data-value') || (o.textContent || '').trim());
});
hidden.value = vals.join(',');
}
el.setAttribute('data-listbox-ready','true');
})(document.currentScript.parentNode.querySelector('[data-slot="listbox"]'));
</script>
</span>
</div>Native fallback — <select multiple>
When you don't need custom option rendering, the platform already ships a listbox: <select multiple> (or size > 1). Zero JS, submits each selected option on its own.
The styled widget above is the APG listbox for when you need custom rendering. But the truly-native <select multiple> is a real listbox too — full keyboard control, accessible name handling, and form submission come from the browser with no JS at all. Reach for it first; reach for the styled listbox when the browser's option chrome isn't enough.
// Reuses the native <Select multiple size={5}> component.
<Select name="langs" multiple size={5}>
<SelectOption value="js">JavaScript</SelectOption>
<SelectOption value="py" selected>Python</SelectOption>
</Select><select name="langs" multiple size="5" class="…">
<option value="js">JavaScript</option>
<option value="py" selected>Python</option>
</select><select name="langs" multiple size="5" class="…">
<option value="js">JavaScript</option>
</select><select name="langs" multiple size="5" class="…">
<option value="js">JavaScript</option>
</select><div class="grid w-full max-w-xs gap-2">
<label for="lb-native" class="flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50" data-slot="label">Languages (native)</label>
<select id="lb-native" name="langs" multiple="" size="5" class="w-full rounded-md border bg-background p-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50">
<option value="js">JavaScript</option>
<option value="py" selected="">Python</option>
<option value="go">Go</option>
<option value="rs">Rust</option>
<option value="ts" selected="">TypeScript</option>
</select>
</div>Further reading
API Reference
<Listbox> — role=listbox / role=option
Props for the JSX <Listbox> container; pass <ListboxOption> children for the options. Anything matching hx-* or data-* is forwarded onto the <ul role="listbox">.
| Prop | Type | Default | Description |
|---|---|---|---|
required | boolean | false | Group-level requirement that one option must be chosen. Sets aria-required on the container. The styled listbox submits via a hidden input, so the native required attribute cannot apply; aria-required is the only way to convey it (mirrors RadioGroup).MDNlistbox role (aria-required) |
ariaInvalid | boolean | — | Mark the widget invalid for the WCAG error-identification pattern (e.g. a required listbox submitted empty, or server validation over htmx). Emits aria-invalid on the container. Pair with ariaErrormessage.MDNaria-invalid |
ariaErrormessage | string | — | Id of a visible element holding the error message. Emits aria-errormessage on the container. Pair with ariaInvalid for the full WCAG error-identification pattern.MDNaria-errormessage |
ariaReadonly | boolean | false | Locked-but-operable: the user cannot change which options are selected but the listbox stays focusable and navigable. Sets aria-readonly. Distinct from disabled, which makes the options inert.MDNaria-readonly |
ariaLabel | string | — | Accessible name for the listbox when there's no visible label. APG requires a standalone listbox to be labelled via aria-label or aria-labelledby.APGListbox roles, states & properties |
ariaLabelledby | string | — | Id of a visible element that names the listbox (alternative to ariaLabel).MDNaria-labelledby |
multiple | boolean | false | Allow more than one option to be selected. Sets aria-multiselectable="true"; Space toggles the focused option and Shift/Ctrl extend the selection per the APG recommended model.MDNaria-multiselectable |
disabled | boolean | false | Disable the whole widget. Sets aria-disabled and makes the options inert. |
orientation | "horizontal"|"vertical" | "vertical" | Layout axis. Sets aria-orientation and selects the arrow-key axis: Up/Down when vertical, Left/Right when horizontal.MDNlistbox role |
name | string | — | Renders a sibling <input type="hidden"> whose value mirrors the selection (single value, or comma-joined when multiple) so the listbox submits like a normal field. Omit to skip the hidden input. |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |