shshadcn-htmx

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

2. Use it

components/ui/listbox.tsx
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
components/ui/listbox.tsx
/** @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

templates/components/listbox.html
{% 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
templates/components/listbox.html
{# 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

components/listbox.tmpl
{{- $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
components/listbox.tmpl
{{/*
  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

lib/my_app_web/components/listbox.ex
<.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
lib/my_app_web/components/listbox.ex
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

snippets/listbox.html
<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
snippets/listbox.html
<!--
  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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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 [&amp;_svg]:pointer-events-none [&amp;_svg]:shrink-0 [&amp;_svg:not([class*=&#39;size-&#39;])]: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>

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

PropTypeDefaultDescription
requiredbooleanfalse
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)
ariaInvalidboolean
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
ariaErrormessagestring
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
ariaReadonlybooleanfalse
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
ariaLabelstring
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
ariaLabelledbystring
Id of a visible element that names the listbox (alternative to ariaLabel).MDNaria-labelledby
multiplebooleanfalse
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
disabledbooleanfalse
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
namestring
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.
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference