shshadcn-htmx

Components

Autocomplete

A free-text input with native typeahead — a real <input list> bound to a <datalist>. The browser owns the dropdown, filtering, and selection; htmx can stream a fresh set of <option> tags in as you type. The light native sibling of the APG combobox — it suggests, it doesn't constrain.

Installation

1. Install via the shadcn CLI

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

2. Use it

components/ui/autocomplete.tsx
import { Autocomplete } from "@/components/ui/autocomplete"

// Static suggestions — the browser filters as the user types.
<Autocomplete
  id="fruit"
  name="fruit"
  placeholder="Search fruit…"
  options={[{ value: "Apple" }, { value: "Apricot" }, { value: "Banana" }]}
/>

// Server-streamed — set endpoint and the component wires the htmx defaults
// (hx-get / hx-trigger / hx-target / hx-swap / hx-sync). The server returns
// <option> tags swapped into the bound <datalist>.
<Autocomplete id="city" name="city" placeholder="Search cities…"
  endpoint="/api/cities" />
Or copy the source manually
components/ui/autocomplete.tsx
/** @jsxImportSource hono/jsx */
import { cn, type ClassValue } from "@/registry/lib/cn"

// Autocomplete — shadcn-htmx, htmx v4 + Tailwind v4.
//
// Free-text input with native typeahead suggestions: a real <input list>
// bound to a <datalist>. The light, native sibling of the APG combobox —
// where the combobox is a full listbox widget, this is the platform's own
// "suggestion list" affordance with zero behavioural JS of our own.
//
// **Native-first.** The browser owns everything:
//   - the suggestion dropdown UI and its positioning
//   - substring filtering of <option> values as the user types
//   - click + Up/Down + Enter selection, Escape to dismiss
//   - focus management and the implicit listbox role of <datalist>
// The value is always free text — an autocomplete *suggests*, it does not
// constrain. (Use <select> / the listbox component when the value must be
// one of a fixed set.)
//   See repos/mdn/files/en-us/web/html/reference/elements/datalist/index.md
//      ("<datalist> is not a replacement for <select>… The control can still
//       accept any value that passes validation.")
//      repos/mdn/files/en-us/web/html/reference/elements/input/index.md#list
//      ("The values provided are suggestions, not requirements.")
//
// htmx wiring (server-streamed suggestions, verified against the vendored
// v4 source). When `endpoint` is set we point htmx at this input and let the
// server return a fresh <option> set on each keystroke:
//   - hx-trigger="input changed delay:Nms" — debounce typing and ignore
//     no-op keys (arrows). The leading `input` event covers every keystroke.
//     See repos/htmx/www/src/content/reference/01-attributes/06-hx-trigger.md
//        ("Events can be refined with filters and modifiers, e.g.
//          `input changed delay:1s`")
//   - hx-target="#<id>-list" + hx-swap="innerHTML" — swap the new options
//     straight into the bound <datalist>; the input keeps focus and the
//     browser re-renders the dropdown from the fresh list transparently.
//   - hx-sync="this:replace" — abort the in-flight request when the next
//     keystroke fires so a slow response can never clobber newer suggestions.
//     See repos/htmx/www/src/content/reference/01-attributes/21-hx-sync.md
//
// Style analogues: registry/ui/combobox.tsx (the datalist sibling) and
// registry/ui/input.tsx / registry/ui/active-search.tsx (the input chrome +
// the htmx defaults + the .htmx-request dimming convention).
//
// No site.js: the dropdown, filtering, and selection are all native; the
// only JS in play is htmx fetching options. data-slot hooks are for
// styling/testing only.

export type AutocompleteOption = { value: string; label?: string }

const inputBase =
  "flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none " +
  "selection:bg-primary selection:text-primary-foreground " +
  "placeholder:text-muted-foreground " +
  "disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 " +
  "md:text-sm dark:bg-input/30 " +
  "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
  "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 " +
  // htmx-request: dim while a suggestion request triggered by this input is
  // in flight, matching the input/active-search convention.
  "[&.htmx-request]:opacity-70"

export function autocompleteInputClasses(opts?: { class?: ClassValue }): string {
  return cn(inputBase, opts?.class)
}

type AutocompleteProps = {
  // The input's id. The <datalist> is `${id}-list`; the default htmx
  // hx-target points at it, so server-streamed options land in the right list.
  id: string
  name?: string
  // Initial / static suggestions. Server-streamed autocompletes pass [] and
  // let htmx populate the datalist on input.
  options?: AutocompleteOption[]
  placeholder?: string
  value?: string
  required?: boolean
  disabled?: boolean
  readonly?: boolean
  autofocus?: boolean
  // Length bounds the platform enforces on the free-text value.
  minLength?: number
  maxLength?: number
  // Debounce window for the `input` trigger when `endpoint` is set. Default 200ms.
  delay?: number
  // Convenience: when set, wires the standard server-streaming defaults
  //   hx-get={endpoint} hx-trigger="input changed delay:${delay}ms"
  //   hx-target="#${id}-list" hx-swap="innerHTML" hx-sync="this:replace"
  // Anything passed via hx-* in `rest` overrides these.
  endpoint?: string
  ariaLabel?: string
  ariaLabelledby?: string
  ariaDescribedby?: string
  ariaInvalid?: boolean | "grammar" | "spelling"
  class?: ClassValue
  inputClass?: ClassValue
  form?: string
  // htmx attrs ride onto the <input>. With `endpoint` you usually need none;
  // pass hx-* directly for full control (they override the endpoint defaults).
  //   See repos/htmx/www/reference.md
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
}

export function Autocomplete(props: AutocompleteProps) {
  const {
    id,
    name,
    options = [],
    placeholder,
    value,
    required,
    disabled,
    readonly,
    autofocus,
    minLength,
    maxLength,
    delay = 200,
    endpoint,
    ariaLabel,
    ariaLabelledby,
    ariaDescribedby,
    ariaInvalid,
    class: className,
    inputClass,
    form,
    ...rest
  } = props

  const listId = `${id}-list`

  // Server-streaming defaults, applied only when an endpoint is given.
  // Anything in `rest` (explicit hx-*) wins.
  const hxDefaults: Record<string, any> = endpoint
    ? {
        "hx-get": endpoint,
        "hx-trigger": `input changed delay:${delay}ms`,
        "hx-target": `#${listId}`,
        "hx-swap": "innerHTML",
        "hx-sync": "this:replace",
      }
    : {}
  const hx = { ...hxDefaults, ...rest }

  return (
    <span data-slot="autocomplete" class={cn("inline-block w-full", className)}>
      <input
        type="text"
        id={id}
        name={name}
        list={listId}
        value={value}
        placeholder={placeholder}
        required={required}
        disabled={disabled}
        readonly={readonly}
        autofocus={autofocus}
        minlength={minLength}
        maxlength={maxLength}
        form={form}
        // autocomplete="off" stops the browser layering its own history
        // suggestions on top of the datalist.
        autocomplete="off"
        aria-label={ariaLabel}
        aria-labelledby={ariaLabelledby}
        aria-describedby={ariaDescribedby}
        aria-invalid={ariaInvalid === undefined ? undefined : String(ariaInvalid)}
        data-slot="autocomplete-input"
        class={autocompleteInputClasses({ class: inputClass })}
        {...hx}
      />
      <datalist id={listId} data-slot="autocomplete-list">
        {options.map((o) => (
          <option value={o.value} label={o.label} />
        ))}
      </datalist>
    </span>
  )
}

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

1. Save the file

Copy autocomplete.html into templates/components/.

2. Use it

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

{{ autocomplete(id="fruit", name="fruit", placeholder="Search fruit…",
                options=[{"value": "Apple"}, {"value": "Apricot"}]) }}

{# Server-streamed #}
{{ autocomplete(id="city", name="city", endpoint="/api/cities") }}
View source
templates/components/autocomplete.html
{# Autocomplete macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Free-text input with native typeahead: <input list> + <datalist>. The
   light native sibling of the APG combobox — the browser owns the dropdown
   UI, substring filtering, click + keyboard selection, and focus. No JS.
   Refs: repos/mdn/.../elements/datalist/index.md,
         repos/mdn/.../elements/input/index.md#list

   Usage (static suggestions):
     {% from "components/autocomplete.html" import autocomplete %}
     {{ autocomplete(id="fruit", name="fruit",
                     options=[{"value": "Apple"}, {"value": "Apricot"}]) }}

   Usage (server-streamed via htmx): pass an endpoint and an empty options
   list; the macro wires the standard streaming defaults and the server
   returns <option> tags swapped into the bound <datalist>.
     {{ autocomplete(id="city", name="city", endpoint="/api/cities") }}
#}

{% macro autocomplete(
    id,
    name=none,
    options=[],
    placeholder=none,
    value=none,
    required=false,
    disabled=false,
    readonly=false,
    minlength=none,
    maxlength=none,
    delay=200,
    endpoint=none,
    aria_label=none,
    aria_labelledby=none,
    aria_describedby=none,
    extra_class="",
    input_class="",
    **attrs
) %}
<span data-slot="autocomplete" class="inline-block w-full {{ extra_class }}">
  <input
    type="text"
    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 readonly %} readonly{% endif %}
    {%- if minlength is not none %} minlength="{{ minlength }}"{% endif %}
    {%- if maxlength is not none %} maxlength="{{ maxlength }}"{% endif %}
    {%- if endpoint %} hx-get="{{ endpoint }}" hx-trigger="input changed delay:{{ delay }}ms" hx-target="#{{ id }}-list" hx-swap="innerHTML" hx-sync="this:replace"{% 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"
    data-slot="autocomplete-input"
    class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&.htmx-request]:opacity-70 {{ input_class }}"
    {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
  >
  <datalist id="{{ id }}-list" data-slot="autocomplete-list">
    {% for opt in options %}<option value="{{ opt.value }}"{% if opt.label %} label="{{ opt.label }}"{% endif %}>{% endfor %}
  </datalist>
</span>
{% endmacro %}

{# A single <option> — useful when an htmx endpoint returns one suggestion. #}
{% macro autocomplete_option(value, label=none) -%}
<option value="{{ value }}"{% if label %} label="{{ label }}"{% endif %}>
{%- endmacro %}

1. Save the file

Add autocomplete.tmpl alongside your templates.

2. Use it

components/autocomplete.tmpl
{{template "autocomplete" (dict
  "ID" "fruit" "Name" "fruit" "Placeholder" "Search fruit…"
  "Options" (list (dict "Value" "Apple") (dict "Value" "Apricot")))}}

{{/* Server-streamed */}}
{{template "autocomplete" (dict "ID" "city" "Name" "city" "Endpoint" "/api/cities")}}
View source
components/autocomplete.tmpl
{{/*
  Autocomplete templates — shadcn-htmx, htmx v4 + Tailwind v4.
  Free-text input with native typeahead: <input list> + <datalist>. The
  browser owns the dropdown UI, filtering, click + keyboard selection and
  focus. No JS. The light native sibling of the APG combobox.
  Refs: repos/mdn/.../elements/datalist/index.md,
        repos/mdn/.../elements/input/index.md#list

      type AutocompleteArgs struct {
          ID, Name, Placeholder, Value string
          Options                      []AutocompleteOption
          Required, Disabled, Readonly bool
          MinLength, MaxLength         string
          AriaLabel, AriaLabelledby, AriaDescribedby string
          // Server-streamed suggestions: set Endpoint (and optionally Delay,
          // default "200") to wire the htmx defaults; leave Options empty and
          // the bound <datalist> is filled by the htmx response.
          Endpoint, Delay string
      }
      type AutocompleteOption struct{ Value, Label string }
*/}}

{{define "autocomplete"}}
{{- $delay := or .Delay "200" -}}
<span data-slot="autocomplete" class="inline-block w-full">
  <input type="text" 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 .MinLength}}minlength="{{.MinLength}}"{{end}}
         {{if .MaxLength}}maxlength="{{.MaxLength}}"{{end}}
         {{if .Endpoint}}hx-get="{{.Endpoint}}" hx-trigger="input changed delay:{{$delay}}ms" hx-target="#{{.ID}}-list" hx-swap="innerHTML" hx-sync="this:replace"{{end}}
         {{if .AriaLabel}}aria-label="{{.AriaLabel}}"{{end}}
         {{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{end}}
         {{if .AriaDescribedby}}aria-describedby="{{.AriaDescribedby}}"{{end}}
         autocomplete="off"
         data-slot="autocomplete-input"
         class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&.htmx-request]:opacity-70">
  <datalist id="{{.ID}}-list" data-slot="autocomplete-list">
    {{range .Options}}<option value="{{.Value}}"{{if .Label}} label="{{.Label}}"{{end}}>{{end}}
  </datalist>
</span>
{{end}}

{{define "autocomplete_option"}}
<option value="{{.Value}}"{{if .Label}} label="{{.Label}}"{{end}}>
{{end}}

1. Save the file

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

2. Use it

lib/my_app_web/components/autocomplete.ex
<.autocomplete id="fruit" name="fruit" placeholder="Search fruit…"
  options={[%{value: "Apple"}, %{value: "Apricot"}]} />

<%# Server-streamed %>
<.autocomplete id="city" name="city" endpoint={~p"/api/cities"} />
View source
lib/my_app_web/components/autocomplete.ex
defmodule ShadcnHtmx.Components.Autocomplete do
  @moduledoc """
  Autocomplete — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  Free-text input with native typeahead suggestions: `<input list>` bound to
  a `<datalist>`. The light, native sibling of the APG combobox — the browser
  owns the dropdown UI, substring filtering, click + keyboard selection, and
  focus management. No custom JS. The value is always free text: an
  autocomplete *suggests*, it does not constrain.

  Refs: repos/mdn/.../elements/datalist/index.md,
        repos/mdn/.../elements/input/index.md#list

  ## Examples

      # Static suggestions
      <.autocomplete id="fruit" name="fruit" placeholder="Search fruit…"
        options={[%{value: "Apple"}, %{value: "Apricot"}, %{value: "Banana"}]} />

      # Server-streamed via htmx — set `endpoint`; the component wires
      # hx-get / hx-trigger / hx-target / hx-swap / hx-sync and the server
      # returns <option> tags swapped into the bound <datalist>.
      <.autocomplete id="city" name="city" placeholder="Search cities…"
        endpoint={~p"/api/cities"} />
      # Endpoint returns: <.autocomplete_option value="Berlin" />
  """

  use Phoenix.Component

  attr :id, :string, required: true
  attr :name, :string, default: nil
  attr :placeholder, :string, default: nil
  attr :value, :string, default: nil
  attr :options, :list, default: []
  attr :required, :boolean, default: false
  attr :disabled, :boolean, default: false
  attr :readonly, :boolean, default: false
  attr :minlength, :integer, default: nil
  attr :maxlength, :integer, default: nil
  attr :delay, :integer, default: 200
  attr :endpoint, :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-sync hx-vals hx-headers form)

  def autocomplete(assigns) do
    assigns =
      assign(assigns, :hx, if(assigns.endpoint, do: stream_attrs(assigns.id, assigns.delay, assigns.endpoint), else: %{}))

    ~H"""
    <span data-slot="autocomplete" class={["inline-block w-full", @class]}>
      <input
        type="text"
        id={@id}
        name={@name}
        list={"#{@id}-list"}
        value={@value}
        placeholder={@placeholder}
        required={@required}
        disabled={@disabled}
        readonly={@readonly}
        minlength={@minlength}
        maxlength={@maxlength}
        autocomplete="off"
        aria-label={assigns[:"aria-label"]}
        aria-labelledby={assigns[:"aria-labelledby"]}
        aria-describedby={assigns[:"aria-describedby"]}
        data-slot="autocomplete-input"
        class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&.htmx-request]:opacity-70"
        {@hx}
        {@rest}
      />
      <datalist id={"#{@id}-list"} data-slot="autocomplete-list">
        <option :for={opt <- @options} value={opt[:value]} label={opt[:label]} />
      </datalist>
    </span>
    """
  end

  # Standard server-streaming htmx defaults; explicit hx-* in @rest override
  # these because @rest is spread after @hx in the markup.
  defp stream_attrs(id, delay, endpoint) do
    %{
      "hx-get" => endpoint,
      "hx-trigger" => "input changed delay:#{delay}ms",
      "hx-target" => "##{id}-list",
      "hx-swap" => "innerHTML",
      "hx-sync" => "this:replace"
    }
  end

  attr :value, :string, required: true
  attr :label, :string, default: nil

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

1. Save the file

Paste the markup; relies only on theme tokens.

2. Use it

snippets/autocomplete.html
<span data-slot="autocomplete" class="inline-block w-full">
  <input type="text" id="fruit" name="fruit" list="fruit-list"
         placeholder="Search fruit…" autocomplete="off"
         data-slot="autocomplete-input" class="…">
  <datalist id="fruit-list" data-slot="autocomplete-list">
    <option value="Apple">
    <option value="Apricot">
  </datalist>
</span>

<!-- Server-streamed: empty datalist filled by htmx on input -->
<span data-slot="autocomplete" class="inline-block w-full">
  <input type="text" id="city" name="city" list="city-list"
         hx-get="/api/cities" hx-trigger="input changed delay:200ms"
         hx-target="#city-list" hx-swap="innerHTML" hx-sync="this:replace"
         autocomplete="off" data-slot="autocomplete-input" class="…">
  <datalist id="city-list" data-slot="autocomplete-list"></datalist>
</span>
View source
snippets/autocomplete.html
<!--
  shadcn-htmx — raw HTML autocomplete snippet.

  Free-text input with native typeahead: <input list> + <datalist>. The
  light native sibling of the APG combobox — the browser handles the dropdown
  UI, substring filtering, click + keyboard selection, and focus. Zero JS.
  The value is always free text; an autocomplete suggests, it does not
  constrain (use <select> when the value must be one of a fixed set).
  Refs: repos/mdn/.../elements/datalist/index.md,
        repos/mdn/.../elements/input/index.md#list

  Two patterns:
    1. Static suggestions — fill the <datalist> at render time.
    2. Server-streamed — leave the <datalist> empty and let htmx fetch a
       fresh <option> set on input. The server returns <option> tags swapped
       into the bound list (hx-target points at the datalist).
-->

<!-- 1. Static suggestions -->
<span data-slot="autocomplete" class="inline-block w-full">
  <input type="text" id="fruit" name="fruit" list="fruit-list"
         placeholder="Search fruit…" autocomplete="off"
         data-slot="autocomplete-input"
         class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&.htmx-request]:opacity-70">
  <datalist id="fruit-list" data-slot="autocomplete-list">
    <option value="Apple">
    <option value="Apricot">
    <option value="Banana">
    <option value="Blackberry">
    <option value="Blueberry">
  </datalist>
</span>

<!-- 2. Server-streamed via htmx -->
<span data-slot="autocomplete" class="inline-block w-full">
  <input type="text" id="city" name="city" list="city-list"
         placeholder="Search cities…" autocomplete="off"
         hx-get="/api/cities"
         hx-trigger="input changed delay:200ms"
         hx-target="#city-list"
         hx-swap="innerHTML"
         hx-sync="this:replace"
         data-slot="autocomplete-input"
         class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&.htmx-request]:opacity-70">
  <datalist id="city-list" data-slot="autocomplete-list">
    <!-- Server returns: -->
    <!-- <option value="Berlin"> -->
    <!-- <option value="Bern"> -->
  </datalist>
</span>

Examples

Static suggestions

Fill the <datalist> at render time. The browser filters the suggestions as the user types and lets them pick one — or type something entirely different, since the value is free text.

An autocomplete is just <input list> pointed at a <datalist> of <option> values. The browser renders the dropdown, does the substring filtering, and handles click / Up-Down / Enter selection and Escape — there is no JavaScript of ours involved. Per MDN, a <datalist> is not a replacement for <select>: the control still accepts any value, so reach for this when you want to suggest, not constrain.

<Autocomplete id="fruit" name="fruit" placeholder="Search fruit…"
  options={[{ value: "Apple" }, { value: "Apricot" }, { value: "Banana" }]} />
{{ autocomplete(id="fruit", name="fruit", placeholder="Search fruit…",
                options=[{"value": "Apple"}, {"value": "Apricot"}]) }}
{{template "autocomplete" (dict "ID" "fruit" "Name" "fruit"
  "Placeholder" "Search fruit…"
  "Options" (list (dict "Value" "Apple") (dict "Value" "Apricot")))}}
<.autocomplete id="fruit" name="fruit" placeholder="Search fruit…"
  options={[%{value: "Apple"}, %{value: "Apricot"}]} />
<div class="grid w-full max-w-sm gap-2">
  <label for="ex-ac-fruit" 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 fruit</label>
  <span data-slot="autocomplete" class="inline-block w-full">
    <input type="text" id="ex-ac-fruit" name="fruit" list="ex-ac-fruit-list" placeholder="Search fruit…" autocomplete="off" data-slot="autocomplete-input" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;.htmx-request]:opacity-70"/>
    <datalist id="ex-ac-fruit-list" data-slot="autocomplete-list">
      <option value="Apple">
      </option>
      <option value="Apricot">
      </option>
      <option value="Banana">
      </option>
      <option value="Blackberry">
      </option>
      <option value="Blueberry">
      </option>
      <option value="Cherry">
      </option>
      <option value="Mango">
      </option>
      <option value="Peach">
      </option>
    </datalist>
  </span>
</div>

Server-streamed suggestions

Pass an endpoint and the component wires the htmx streaming defaults: each keystroke (debounced 200ms) fetches a fresh <option> set, swapped straight into the bound <datalist>. hx-sync="this:replace" cancels the in-flight request so stale suggestions never land.

For large or remote data, leave the <datalist> empty and set endpoint. The component applies hx-trigger="input changed delay:200ms" to debounce typing, hx-target="#<id>-list" hx-swap="innerHTML" to drop the new options into the bound list, and hx-sync="this:replace" to abort a slow request when the next keystroke fires. The browser re-renders the dropdown from the fresh list with no code from us. Type two letters of a city below.

<Autocomplete id="city" name="city" placeholder="Search cities…"
  endpoint="/api/cities" />
{/* Server returns <option> tags for the query, e.g.
    <option value="Berlin"><option value="Bern"> */}
{{ autocomplete(id="city", name="city", endpoint="/api/cities") }}
{# Endpoint returns: {{ autocomplete_option("Berlin") }} #}
{{template "autocomplete" (dict "ID" "city" "Name" "city"
  "Endpoint" "/api/cities")}}
{{/* Endpoint returns: {{template "autocomplete_option" (dict "Value" "Berlin")}} */}}
<.autocomplete id="city" name="city" endpoint={~p"/api/cities"} />
<%# Endpoint returns: <.autocomplete_option value="Berlin" /> %>
<div class="grid w-full max-w-sm gap-2">
  <label for="ex-ac-city" 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">City</label>
  <span data-slot="autocomplete" class="inline-block w-full">
    <input type="text" id="ex-ac-city" name="city" list="ex-ac-city-list" placeholder="Try &quot;be&quot; or &quot;lo&quot;…" autocomplete="off" data-slot="autocomplete-input" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&amp;.htmx-request]:opacity-70" hx-get="/docs/autocomplete/suggest" hx-trigger="input changed delay:200ms" hx-target="#ex-ac-city-list" hx-swap="innerHTML" hx-sync="this:replace"/>
    <datalist id="ex-ac-city-list" data-slot="autocomplete-list">
    </datalist>
  </span>
</div>

API Reference

Autocomplete

PropTypeDefaultDescription
id*string
Input id, and the base for the bound list id (`${id}-list`). The default htmx hx-target points at that datalist, so server-streamed options land in the right list.
namestring
Form field name on submit. The submitted value is the free text in the input.
optionsAutocompleteOption[][]
Static suggestions rendered as <option> in the bound <datalist>. Each is { value: string; label?: string }. Server-streamed autocompletes pass [] and let htmx populate the list on input.MDN<datalist>
endpointstring
When set, wires the htmx streaming defaults: hx-get={endpoint}, hx-trigger="input changed delay:${delay}ms", hx-target="#${id}-list", hx-swap="innerHTML", hx-sync="this:replace". The server returns a fresh <option> set per keystroke. Explicit hx-* props override these.htmxhx-trigger (input changed delay)
delaynumber200
Debounce window (ms) for the input trigger when endpoint is set.
valuestring
Initial free-text value.
placeholderstring
Placeholder text when empty.
liststring
Underlying native attribute the component sets to `${id}-list`. The value is always free text — a <datalist> suggests, it does not constrain (use Select / Listbox for a fixed value set).MDN<input> list attribute
minLength / maxLengthnumber
Character bounds the platform enforces on the value.
requiredbooleanfalse
Native HTML required for form validation.
disabledbooleanfalse
Disable — unfocusable, not submitted.
readonlybooleanfalse
Read-only — focusable + selectable but not editable.
autofocusbooleanfalse
Focus this input on initial page load (one per document).
ariaInvalid"true"|"false"|"grammar"|"spelling"|boolean
Sets aria-invalid; drives the destructive border + ring styling.MDNaria-invalid
formstring
Associate the input with a <form> by id when it lives outside it.
inputClassstring
Extra Tailwind classes appended to the <input> (root takes `class`).
ariaLabelstring
Accessible name when no visible <label>.MDNaria-label
ariaLabelledbystring
Id of a visible element providing the accessible name.MDNaria-labelledby
ariaDescribedbystring
Id of an element describing this control (announced after the name).MDNaria-describedby
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference

* required