shshadcn-htmx

Components

Combobox

Native <input list> + <datalist>. The browser handles dropdown UI, filtering, click + keyboard selection, focus management — zero custom JS. For server-driven options, point hx-target at the <datalist>.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/combobox.json

2. Use it

components/ui/combobox.tsx
import { Combobox } from "@/components/ui/combobox"

// Static — zero JS, browser handles dropdown + filter.
<Combobox id="lang" name="lang" placeholder="Pick a language…"
  options={[{ value: "JavaScript" }, { value: "Python" }, { value: "Go" }]} />

// Server-filtered — htmx targets the <datalist>; server returns <option> tags.
<Combobox id="user" name="user" placeholder="Search users…"
  hx-get="/api/users/search"
  hx-trigger="input changed delay:200ms"
  hx-target="#user-list"
  hx-swap="innerHTML"
/>
Or copy the source manually
components/ui/combobox.tsx
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"

// Combobox — shadcn-htmx, htmx v4 + Tailwind v4.
//
// **Native-first.** This component is just `<input list>` + `<datalist>`.
// The browser handles:
//   - The dropdown UI
//   - Filtering as the user types
//   - Click + keyboard selection
//   - Focus management
//   - aria-controls / aria-expanded wiring (implicit)
//
// No custom JS event handlers, no MutationObserver, no race conditions.
// If you need server-driven suggestions, point htmx at the <datalist> and
// have the server return `<option>` tags — the browser uses them
// transparently.
//
// Refs:
//   repos/mdn/files/en-us/web/html/reference/elements/datalist/index.md
//   repos/mdn/files/en-us/web/html/reference/elements/input/index.md#list
//   repos/aria-practices/content/patterns/combobox/

// `disabled` marks an option non-checkable (browsers grey it out, it gets
// no click/focus events).
// repos/mdn/files/en-us/web/html/reference/elements/option/index.md:45
export type ComboboxOption = { value: string; label?: string; disabled?: boolean }

type ComboboxProps = {
  // The input's id. The <datalist> gets `${id}-list`; if you wire htmx
  // to fetch options dynamically, target that id.
  id: string
  name?: string
  // Initial options. Server-filtered comboboxes can pass [] and let
  // htmx populate the datalist on input.
  options?: ComboboxOption[]
  // `list` is valid on these 13 input types, not just text — a url/email/
  // search combobox, or a number/date/time/range/color picker with suggested
  // values, are all native datalist use cases.
  // repos/mdn/files/en-us/web/html/reference/elements/input/index.md:492
  type?:
    | "text"
    | "search"
    | "url"
    | "tel"
    | "email"
    | "number"
    | "date"
    | "datetime-local"
    | "month"
    | "week"
    | "time"
    | "range"
    | "color"
  placeholder?: string
  value?: string
  required?: boolean
  disabled?: boolean
  // The user can type any value that passes validation, even one not in the
  // suggestion list, so constrain the free-typed value with these.
  // repos/mdn/files/en-us/web/html/reference/elements/input/index.md:505
  maxlength?: number
  minlength?: number
  pattern?: string
  // Explains the pattern to AT / on validation failure (spec accessibility note).
  title?: string
  // Focusable + copy-selectable but not editable. Not supported on range/color.
  // repos/mdn/files/en-us/web/html/reference/elements/input/index.md:588
  readonly?: boolean
  ariaLabel?: string
  ariaLabelledby?: string
  ariaDescribedby?: string
  class?: ClassValue
  // htmx attrs ride onto the input element. Typical server-filter setup:
  //   hx-get="/api/search"
  //   hx-trigger="input changed delay:200ms"
  //   hx-target="#<id>-list"       (points at the datalist)
  //   hx-swap="innerHTML"
  [key: `hx-${string}`]: any
}

export function Combobox(props: ComboboxProps) {
  const {
    id,
    name,
    options = [],
    type = "text",
    placeholder,
    value,
    required,
    disabled,
    maxlength,
    minlength,
    pattern,
    title,
    readonly,
    ariaLabel,
    ariaLabelledby,
    ariaDescribedby,
    class: className,
    ...rest
  } = props
  const listId = `${id}-list`
  return (
    <span data-slot="combobox" class={cn("inline-block w-full", className)}>
      <input
        type={type}
        id={id}
        name={name}
        list={listId}
        value={value}
        placeholder={placeholder}
        required={required}
        disabled={disabled}
        maxlength={maxlength}
        minlength={minlength}
        pattern={pattern}
        title={title}
        readonly={readonly}
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledby}
        aria-describedby={ariaDescribedby}
        // autocomplete="off" stops the browser from layering its own
        // history-based suggestions on top of the datalist.
        autocomplete="off"
        class="flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
        {...rest}
      />
      <datalist id={listId} data-slot="combobox-list">
        {options.map((o) => (
          <option value={o.value} label={o.label} disabled={o.disabled} />
        ))}
      </datalist>
    </span>
  )
}

// Server-rendered single option used by htmx endpoints. Lets the server
// return a typed component instead of raw HTML strings.
export function ComboboxOption(props: ComboboxOption) {
  return <option value={props.value} label={props.label} disabled={props.disabled} />
}

// Back-compat shim: the previous API exposed ComboboxNative for the
// static-options use case and Combobox for the custom richer one. We
// collapsed both into a single native Combobox; export the old name as
// an alias so existing import sites don't break.
export const ComboboxNative = Combobox

// Back-compat: ComboboxItem used to be the rich custom variant's option
// renderer. With native datalist, the right primitive is a plain
// <option> — exported via ComboboxOption above. Keeping the alias so
// existing consumers don't break.
export const ComboboxItem = ComboboxOption

1. Save the file

Copy combobox.html into templates/components/.

2. Use it

templates/components/combobox.html
{% from "components/combobox.html" import combobox %}

{# Static options #}
{{ combobox(id="lang", name="lang", placeholder="Pick a language…",
            options=[{"value": "JavaScript"}, {"value": "Python"}, {"value": "Go"}]) }}

{# Server-filter — htmx populates the <datalist> #}
{{ combobox(id="user", name="user", placeholder="Search users…",
            hx_get="/api/users/search",
            hx_trigger="input changed delay:200ms",
            hx_target="#user-list",
            hx_swap="innerHTML") }}
View source
templates/components/combobox.html
{# Combobox macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Native <input list> + <datalist>. Browser handles dropdown UI, filter,
   click + keyboard selection, focus management. No custom JS required.

   Usage (static options):
     {% from "components/combobox.html" import combobox %}
     {{ combobox(id="lang", name="lang",
                 options=[{"value": "JavaScript"}, {"value": "Python"}]) }}

   Usage (htmx-driven server-filter): target the <datalist> by id and
   return <option> tags from the server.
     <input list="user-list" hx-get="/api/search"
            hx-trigger="input changed delay:200ms"
            hx-target="#user-list" hx-swap="innerHTML">
     <datalist id="user-list"></datalist>
#}

{# `type`: `list` is valid on 13 input types, not just text.
   repos/mdn/files/en-us/web/html/reference/elements/input/index.md:492
   maxlength/minlength/pattern constrain the free-typed value (datalist
   suggestions are not requirements); readonly is not supported on range/color.
   repos/mdn/files/en-us/web/html/reference/elements/input/index.md:505,588 #}
{% macro combobox(
    id,
    name=none,
    options=[],
    type="text",
    placeholder=none,
    value=none,
    required=false,
    disabled=false,
    maxlength=none,
    minlength=none,
    pattern=none,
    title=none,
    readonly=false,
    aria_label=none,
    aria_labelledby=none,
    aria_describedby=none,
    extra_class="",
    **attrs
) %}
<span data-slot="combobox" class="inline-block w-full {{ extra_class }}">
  <input
    type="{{ type }}"
    id="{{ id }}"
    {%- if name %} name="{{ name }}"{% endif %}
    list="{{ id }}-list"
    {%- if value is not none %} value="{{ value }}"{% endif %}
    {%- if placeholder %} placeholder="{{ placeholder }}"{% endif %}
    {%- if required %} required{% endif %}
    {%- if disabled %} disabled{% endif %}
    {%- if maxlength is not none %} maxlength="{{ maxlength }}"{% endif %}
    {%- if minlength is not none %} minlength="{{ minlength }}"{% endif %}
    {%- if pattern %} pattern="{{ pattern }}"{% endif %}
    {%- if title %} title="{{ title }}"{% endif %}
    {%- if readonly %} readonly{% endif %}
    {%- if aria_label %} aria-label="{{ aria_label }}"{% endif %}
    {%- if aria_labelledby %} aria-labelledby="{{ aria_labelledby }}"{% endif %}
    {%- if aria_describedby %} aria-describedby="{{ aria_describedby }}"{% endif %}
    autocomplete="off"
    class="flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
    {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
  >
  <datalist id="{{ id }}-list" data-slot="combobox-list">
    {% for opt in options %}<option value="{{ opt.value }}"{% if opt.label %} label="{{ opt.label }}"{% endif %}{% if opt.disabled %} disabled{% endif %}>{% endfor %}
  </datalist>
</span>
{% endmacro %}

{# A single <option> — useful when an htmx endpoint returns one.
   `disabled` marks it non-checkable.
   repos/mdn/files/en-us/web/html/reference/elements/option/index.md:45 #}
{% macro combobox_option(value, label=none, disabled=false) -%}
<option value="{{ value }}"{% if label %} label="{{ label }}"{% endif %}{% if disabled %} disabled{% endif %}>
{%- endmacro %}

1. Save the file

Add combobox.tmpl alongside button.tmpl.

2. Use it

templates/components/combobox.tmpl
{{/* Static options */}}
{{template "combobox" (dict "ID" "lang" "Name" "lang" "Options" $opts)}}

{{/* Server-filter — htmx populates the <datalist> */}}
{{template "combobox" (dict "ID" "user" "Name" "user"
  "HxGet" "/api/users/search" "HxTrigger" "input changed delay:200ms"
  "HxTarget" "#user-list" "HxSwap" "innerHTML")}}
View source
templates/components/combobox.tmpl
{{/*
  Combobox templates — shadcn-htmx, htmx v4 + Tailwind v4.
  Native <input list> + <datalist>. Browser handles everything; no JS.

      type ComboboxArgs struct {
          ID, Name, Placeholder, Value, AriaLabel string
          // Type: `list` is valid on 13 input types, not just text (default).
          // repos/mdn/files/en-us/web/html/reference/elements/input/index.md:492
          Type string
          Options []ComboboxOption
          Required, Disabled, Readonly bool
          // Constrain the free-typed value (datalist suggestions are not
          // requirements). Pattern explained to AT via Title.
          // repos/mdn/files/en-us/web/html/reference/elements/input/index.md:505
          MaxLength, MinLength int
          Pattern, Title       string
          AriaLabelledby, AriaDescribedby string
          // For server-filter: pass HxGet/HxTrigger/HxTarget/HxSwap and
          // an empty Options slice; the datalist will be populated by
          // htmx response.
          HxGet, HxTrigger, HxTarget, HxSwap string
      }
      // Disabled marks an option non-checkable.
      // repos/mdn/files/en-us/web/html/reference/elements/option/index.md:45
      type ComboboxOption struct{ Value, Label string; Disabled bool }
*/}}

{{define "combobox"}}
<span data-slot="combobox" class="inline-block w-full">
  <input type="{{if .Type}}{{.Type}}{{else}}text{{end}}" id="{{.ID}}" {{if .Name}}name="{{.Name}}"{{end}} list="{{.ID}}-list"
         {{if .Value}}value="{{.Value}}"{{end}}
         {{if .Placeholder}}placeholder="{{.Placeholder}}"{{end}}
         {{if .Required}}required{{end}} {{if .Disabled}}disabled{{end}}
         {{if .Readonly}}readonly{{end}}
         {{if .MaxLength}}maxlength="{{.MaxLength}}"{{end}}
         {{if .MinLength}}minlength="{{.MinLength}}"{{end}}
         {{if .Pattern}}pattern="{{.Pattern}}"{{end}}
         {{if .Title}}title="{{.Title}}"{{end}}
         {{if .AriaLabel}}aria-label="{{.AriaLabel}}"{{end}}
         {{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{end}}
         {{if .AriaDescribedby}}aria-describedby="{{.AriaDescribedby}}"{{end}}
         {{if .HxGet}}hx-get="{{.HxGet}}"{{end}}
         {{if .HxTrigger}}hx-trigger="{{.HxTrigger}}"{{end}}
         {{if .HxTarget}}hx-target="{{.HxTarget}}"{{end}}
         {{if .HxSwap}}hx-swap="{{.HxSwap}}"{{end}}
         autocomplete="off"
         class="flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm">
  <datalist id="{{.ID}}-list" data-slot="combobox-list">
    {{range .Options}}<option value="{{.Value}}"{{if .Label}} label="{{.Label}}"{{end}}{{if .Disabled}} disabled{{end}}>{{end}}
  </datalist>
</span>
{{end}}

{{define "combobox_option"}}
<option value="{{.Value}}"{{if .Label}} label="{{.Label}}"{{end}}{{if .Disabled}} disabled{{end}}>
{{end}}

1. Save the file

Drop combobox.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/combobox.ex
<%# Static options %>
<.combobox id="lang" name="lang" placeholder="Pick a language…"
  options={[%{value: "JavaScript"}, %{value: "Python"}, %{value: "Go"}]} />

<%# Server-filter — htmx populates the <datalist> %>
<.combobox id="user" name="user" placeholder="Search users…"
  hx-get={~p"/api/users/search"}
  hx-trigger="input changed delay:200ms"
  hx-target="#user-list"
  hx-swap="innerHTML" />
View source
lib/my_app_web/components/combobox.ex
defmodule ShadcnHtmx.Components.Combobox do
  @moduledoc """
  Combobox — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Native `<input list>` + `<datalist>`. The browser handles the dropdown
  UI, filter, click + keyboard selection, focus management. No custom JS.

  ## Examples

      # Static options
      <.combobox id="lang" name="lang" placeholder="Pick a language…"
        options={[%{value: "JavaScript"}, %{value: "Python"}, %{value: "Go"}]} />

      # Server-filter via htmx — target the datalist by id.
      <.combobox id="user" name="user" placeholder="Search users…"
        hx-get={~p"/api/users/search"}
        hx-trigger="input changed delay:200ms"
        hx-target="#user-list"
        hx-swap="innerHTML" />
      # Endpoint returns: <.combobox_option value="ada" />
  """

  use Phoenix.Component

  attr :id, :string, required: true
  attr :name, :string, default: nil
  # `list` is valid on 13 input types, not just text.
  # repos/mdn/files/en-us/web/html/reference/elements/input/index.md:492
  attr :type, :string, default: "text"
  attr :placeholder, :string, default: nil
  attr :value, :string, default: nil
  attr :options, :list, default: []
  attr :required, :boolean, default: false
  attr :disabled, :boolean, default: false
  # Focusable + selectable but not editable. Not supported on range/color.
  # repos/mdn/files/en-us/web/html/reference/elements/input/index.md:588
  attr :readonly, :boolean, default: false
  # Constrain the free-typed value — datalist suggestions are not requirements.
  # repos/mdn/files/en-us/web/html/reference/elements/input/index.md:505
  attr :maxlength, :integer, default: nil
  attr :minlength, :integer, default: nil
  attr :pattern, :string, default: nil
  attr :title, :string, default: nil
  attr :"aria-label", :string, default: nil
  attr :"aria-labelledby", :string, default: nil
  attr :"aria-describedby", :string, default: nil
  attr :class, :string, default: nil

  attr :rest, :global,
    include: ~w(hx-get hx-post hx-trigger hx-target hx-swap hx-vals hx-headers)

  def combobox(assigns) do
    ~H"""
    <span data-slot="combobox" class={["inline-block w-full", @class]}>
      <input
        type={@type}
        id={@id}
        name={@name}
        list={"#{@id}-list"}
        value={@value}
        placeholder={@placeholder}
        required={@required}
        disabled={@disabled}
        readonly={@readonly}
        maxlength={@maxlength}
        minlength={@minlength}
        pattern={@pattern}
        title={@title}
        aria-label={assigns[:"aria-label"]}
        aria-labelledby={assigns[:"aria-labelledby"]}
        aria-describedby={assigns[:"aria-describedby"]}
        autocomplete="off"
        class="flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
        {@rest}
      />
      <datalist id={"#{@id}-list"} data-slot="combobox-list">
        <option
          :for={opt <- @options}
          value={opt[:value]}
          label={opt[:label]}
          disabled={opt[:disabled]}
        />
      </datalist>
    </span>
    """
  end

  attr :value, :string, required: true
  attr :label, :string, default: nil
  # Marks the option non-checkable (browsers grey it out).
  # repos/mdn/files/en-us/web/html/reference/elements/option/index.md:45
  attr :disabled, :boolean, default: false

  def combobox_option(assigns) do
    ~H"""
    <option value={@value} label={@label} disabled={@disabled} />
    """
  end
end

1. Save the file

Static variant: zero JS. Server-filter variant: htmx populates the <datalist>.

2. Use it

index.html
<!-- Static options -->
<input type="text" id="lang" list="lang-list" autocomplete="off" class="…">
<datalist id="lang-list">
  <option value="JavaScript">
  <option value="Python">
</datalist>

<!-- Server-filter via htmx -->
<input type="text" id="user" list="user-list" autocomplete="off"
       hx-get="/api/users/search"
       hx-trigger="input changed delay:200ms"
       hx-target="#user-list"
       hx-swap="innerHTML" class="…">
<datalist id="user-list"></datalist>
View source
index.html
<!--
  shadcn-htmx — raw HTML combobox snippet.

  Native <input list> + <datalist>. The browser handles dropdown UI,
  filter, click + keyboard selection, focus management. Zero JS.

  Two patterns:
    1. Static options — fill the <datalist> at render time.
    2. Server-filter — leave the <datalist> empty and let htmx populate
       it on input. Server returns <option> tags.

  Native attrs you can add to the <input> (all optional):
    - type: `list` is valid on 13 types (text/search/url/tel/email/number/
      date/datetime-local/month/week/time/range/color), so swap type="text"
      for e.g. type="url" to get a URL combobox.
      repos/mdn/files/en-us/web/html/reference/elements/input/index.md:492
    - maxlength / minlength / pattern / title: constrain the free-typed value
      — datalist suggestions are not requirements, the user can type anything
      that passes validation.
      repos/mdn/files/en-us/web/html/reference/elements/input/index.md:505
    - readonly: focusable + copy-selectable but not editable (not on range/color).
    - aria-describedby: id of a hint / error element announced after the name.
  An <option> can carry `disabled` to render a non-checkable suggestion.
    repos/mdn/files/en-us/web/html/reference/elements/option/index.md:45
-->

<!-- 1. Static options -->
<span data-slot="combobox" class="inline-block w-full">
  <input type="text" id="lang" name="lang" list="lang-list"
         placeholder="Pick a language…" autocomplete="off"
         class="flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm">
  <datalist id="lang-list" data-slot="combobox-list">
    <option value="JavaScript">
    <option value="TypeScript">
    <option value="Python">
    <option value="Go">
    <option value="Rust">
    <!-- A non-checkable suggestion (greyed out, no click/focus). -->
    <option value="COBOL" disabled>
  </datalist>
</span>

<!-- 2. Server-filter via htmx -->
<span data-slot="combobox" class="inline-block w-full">
  <input type="text" id="user" name="user" list="user-list"
         placeholder="Search users…" autocomplete="off"
         hx-get="/api/users/search"
         hx-trigger="input changed delay:200ms"
         hx-target="#user-list"
         hx-swap="innerHTML"
         class="flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm">
  <datalist id="user-list" data-slot="combobox-list">
    <!-- Server returns: -->
    <!-- <option value="Ada Lovelace"> -->
    <!-- <option value="Grace Hopper"> -->
  </datalist>
</span>

Examples

Native — <input list> + <datalist>

Browser handles the dropdown + filter. Best for static, known lists where you don't need custom item rendering.

Native <datalist> is the simplest combobox: zero JS, full keyboard contract comes from the platform, and AT support is solid. The trade-off: option rendering is the browser's chrome, not your CSS. If you need rich items (avatar + name + label), use the htmx variant below.

<Combobox id="lang" name="lang" placeholder="Pick a language…"
  options={[{ value: "JavaScript" }, { value: "Python" }, { value: "Go" }]} />
{{ combobox(id="lang", placeholder="Pick a language…",
            options=[{"value":"JavaScript"},{"value":"Python"},{"value":"Go"}]) }}
{{template "combobox" (dict "ID" "lang" "Placeholder" "Pick a language…" "Options" $opts)}}
<.combobox id="lang" placeholder="Pick a language…"
  options={[%{value: "JavaScript"}, %{value: "Python"}, %{value: "Go"}]} />
<div class="grid w-full max-w-md gap-2">
  <label for="ex-combo-lang" 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 language</label>
  <span data-slot="combobox" class="inline-block w-full">
    <input type="text" id="ex-combo-lang" name="lang" list="ex-combo-lang-list" placeholder="Type to filter…" autocomplete="off" class="flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"/>
    <datalist id="ex-combo-lang-list" data-slot="combobox-list">
      <option value="Bash">
      </option>
      <option value="C">
      </option>
      <option value="C++">
      </option>
      <option value="C#">
      </option>
      <option value="Clojure">
      </option>
      <option value="CoffeeScript">
      </option>
      <option value="Crystal">
      </option>
      <option value="Dart">
      </option>
      <option value="Elixir">
      </option>
      <option value="Elm">
      </option>
      <option value="Erlang">
      </option>
      <option value="F#">
      </option>
    </datalist>
  </span>
</div>

Server — htmx filter into the datalist

Each keystroke (debounced 200ms) fetches /search?lang=…; the server returns <option> tags swapped into the <datalist>.

Same native primitive — the htmx-driven version just points hx-target at the <datalist> instead of populating it server-side at render time. The server returns <option value="…"> tags. Browser handles the dropdown, filter, click, and keyboard selection. **No custom JS, no race conditions.**

<Combobox id="user" name="user"
  placeholder="Search users…"
  hx-get="/api/users/search"
  hx-trigger="input changed delay:200ms"
  hx-target="#user-list"
  hx-swap="innerHTML"
/>
{/* Server returns: <option value="Ada Lovelace"> ... */}
{{ combobox(id="user", placeholder="Search users…",
            hx_get="/api/search",
            hx_trigger="input changed delay:200ms",
            hx_target="#user-list", hx_swap="innerHTML") }}
{{template "combobox" (dict "ID" "user" "Placeholder" "Search users…"
  "HxGet" "/api/search" "HxTrigger" "input changed delay:200ms"
  "HxTarget" "#user-list" "HxSwap" "innerHTML")}}
<.combobox id="user" placeholder="Search users…"
  hx-get={~p"/api/search"} hx-trigger="input changed delay:200ms"
  hx-target="#user-list" hx-swap="innerHTML" />
<div class="grid w-full max-w-md gap-2">
  <label for="ex-combo-server" 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">Language</label>
  <span data-slot="combobox" class="inline-block w-full">
    <input type="text" id="ex-combo-server" name="lang" list="ex-combo-server-list" placeholder="Start typing… (try &quot;ja&quot;)" autocomplete="off" class="flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm" hx-get="/combobox/search" hx-trigger="input changed delay:200ms" hx-target="#ex-combo-server-list" hx-swap="innerHTML"/>
    <datalist id="ex-combo-server-list" data-slot="combobox-list">
    </datalist>
  </span>
</div>

API Reference

<Combobox> — native <input list> + <datalist>

Props you pass to the JSX component. Anything matching hx-* is forwarded onto the underlying <input>. The browser handles dropdown UI, filter, click + keyboard selection, focus management. No custom JS.

PropTypeDefaultDescription
type"text"|"search"|"url"|"tel"|"email"|"number"|"date"|"datetime-local"|"month"|"week"|"time"|"range"|"color""text"
Native input type. list is valid on these 13 types, so you can build a url/email/search combobox or a number/date/time/range/color picker with suggested values.MDN<input list> valid types
minlength / maxlengthnumber
Character bounds for the free-typed value (text-like types).
patternstring
Regex the typed value must match — datalist suggestions are not requirements, so the free-typed value still needs validation.MDNpattern
titlestring
Explains the pattern constraint to assistive tech and on validation failure.
readonlybooleanfalse
Focusable + copy-selectable but not editable. Not supported on range/color types.
disabled (on options)booleanfalse
Per-option flag on ComboboxOption — marks a suggestion non-checkable (greyed out, no click/focus).MDN<option disabled>
id*string
Pairs the input with the <datalist> via list="{id}-list".
options*Array<{ value: string; label?: string }>
Choices the browser will render in the native dropdown.MDN<datalist>
namestring
Form field name.
placeholderstring
Placeholder text shown when empty.
valuestring
Initial value.
requiredbooleanfalse
Native HTML required.
disabledbooleanfalse
Disable the input.
ariaLabelstring
Accessible name when no visible <label>.
ariaLabelledbystring
Id of a visible label.
classstring
Tailwind classes appended to the wrapper.

* required