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.json2. Use it
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
/** @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
{% 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
{# 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
{{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
{{/*
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
<.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
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
<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
<!--
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 [&.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>Further reading
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 "be" or "lo"…" 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" 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
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
name | string | — | Form field name on submit. The submitted value is the free text in the input. |
options | AutocompleteOption[] | [] | 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> |
endpoint | string | — | 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) |
delay | number | 200 | Debounce window (ms) for the input trigger when endpoint is set. |
value | string | — | Initial free-text value. |
placeholder | string | — | Placeholder text when empty. |
list | string | — | 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 / maxLength | number | — | Character bounds the platform enforces on the value. |
required | boolean | false | Native HTML required for form validation. |
disabled | boolean | false | Disable — unfocusable, not submitted. |
readonly | boolean | false | Read-only — focusable + selectable but not editable. |
autofocus | boolean | false | 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 |
form | string | — | Associate the input with a <form> by id when it lives outside it. |
inputClass | string | — | Extra Tailwind classes appended to the <input> (root takes `class`). |
ariaLabel | string | — | Accessible name when no visible <label>.MDNaria-label |
ariaLabelledby | string | — | Id of a visible element providing the accessible name.MDNaria-labelledby |
ariaDescribedby | string | — | Id of an element describing this control (announced after the name).MDNaria-describedby |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |
* required