Components
Active Search
A debounced <input type="search"> that filters an external results list as you type, with an inline loading indicator and stale-request cancellation. It's a real <form>, so Enter submits a normal search even with JavaScript off.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/active-search.json2. Use it
import { ActiveSearch } from "@/components/ui/active-search"
// Debounced live filter → results table. Enter submits a normal GET search
// with no JS; htmx upgrades each keystroke into a debounced fetch and the
// in-flight request is cancelled when the next one fires (hx-sync).
<ActiveSearch
id="contacts"
name="q"
action="/contacts/search" // no-JS fallback + the htmx GET URL
placeholder="Search contacts…"
hx-target="#contact-rows"
hx-swap="innerHTML"
/>
<table>
<tbody id="contact-rows"></tbody>
</table>Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { Child } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Active Search — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A debounced live-search box that filters an external results list/table as
// the user types, with an inline loading indicator and stale-request
// cancellation. It degrades to a normal GET search on Enter when JS is off.
//
// **Native-first.** The control is a real <form> wrapping <input type="search">.
// - The <form action> means Enter submits a normal navigation search with
// zero JS — progressive enhancement, not emulation.
// - <input type="search"> gives the platform clear-field affordance + a
// `search` event that fires on Enter and when the field is cleared.
// We add `search` to hx-trigger so clearing re-runs the filter.
// See repos/mdn/files/en-us/web/html/reference/elements/input/search/index.md
// repos/mdn/files/en-us/web/api/htmlinputelement/search_event/index.md
//
// htmx wiring (verified against the vendored v4 source):
// - hx-trigger="input changed delay:Nms, search" — `input changed delay`
// debounces keystrokes and ignores no-op keys (arrows); the `search`
// event covers Enter + the native clear button.
// See repos/htmx/www/src/content/reference/01-attributes/06-hx-trigger.md
// repos/htmx/www/src/content/patterns/02-forms/01-active-search.md
// - hx-sync="this:replace" — aborts the in-flight request and replaces it
// with the latest one, so stale responses never clobber fresh input.
// See repos/htmx/www/src/content/reference/01-attributes/21-hx-sync.md
// - hx-indicator — htmx toggles the `.htmx-request` class on the indicator
// while a request is in flight; we drive an opacity transition off it.
// See repos/htmx/www/src/content/reference/01-attributes/19-hx-indicator.md
//
// No custom JS: the debounce, cancellation, and indicator are all htmx; the
// no-JS fallback is the native <form>. data-slot is for styling/testing hooks.
const formBase = "relative w-full"
const inputBase =
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent py-1 pr-9 pl-9 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 " +
// Hide the WebKit clear button — htmx's `search` trigger + our spinner is
// the affordance, and the native X overlaps the indicator.
"[&::-webkit-search-cancel-button]:hidden"
// Leading magnifier icon, centred in the left padding gutter.
const searchIconClass =
"pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
// Trailing spinner. `htmx-indicator` is hidden by default; htmx adds
// `.htmx-request` to it (via hx-indicator) while the request is in flight,
// fading it in. role=status + aria-live="polite" announces "Searching…" to
// assistive tech without stealing focus.
const indicatorClass =
"htmx-indicator pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground"
export type ActiveSearchProps = {
// Input id (and the base for the indicator id: `${id}-indicator`).
id: string
name?: string
placeholder?: string
value?: string
// No-JS fallback: where the <form> navigates on Enter when htmx is absent.
// Also the htmx request URL when hx-get isn't passed explicitly.
action?: string
// GET keeps the search idempotent and the no-JS fallback shareable as a URL.
method?: "get" | "post"
// Debounce window for the `input` trigger. Default 300ms.
delay?: number
required?: boolean
disabled?: boolean
autofocus?: boolean
// Visible loading text for screen readers (defaults to "Searching…").
loadingLabel?: string
ariaLabel?: string
ariaLabelledby?: string
ariaDescribedby?: string
class?: ClassValue
inputClass?: ClassValue
// Optional extra content rendered after the input inside the <form>
// (e.g. a visually-hidden submit button for no-JS keyboards). Usually unused.
children?: Child
// htmx attrs ride onto the <input>. Typical setup:
// hx-get="/search" hx-target="#results" hx-swap="innerHTML"
// hx-trigger / hx-sync / hx-indicator are supplied with sensible defaults
// below but can be overridden by passing them explicitly.
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
}
export function ActiveSearch(props: ActiveSearchProps) {
const {
id,
name = "q",
placeholder = "Search…",
value,
action,
method = "get",
delay = 300,
required,
disabled,
autofocus,
loadingLabel = "Searching…",
ariaLabel,
ariaLabelledby,
ariaDescribedby,
class: className,
inputClass,
children,
...rest
} = props
const indicatorId = `${id}-indicator`
// Defaults that make the search "active". Anything passed in `rest`
// (hx-trigger / hx-sync / hx-indicator / hx-get …) overrides these.
const hxDefaults: Record<string, any> = {
"hx-get": action,
"hx-trigger": `input changed delay:${delay}ms, search`,
"hx-sync": "this:replace",
"hx-indicator": `#${indicatorId}`,
}
const hx = { ...hxDefaults, ...rest }
return (
<form
data-slot="active-search"
role="search"
class={cn(formBase, className)}
action={action}
method={method}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={searchIconClass}
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
type="search"
id={id}
name={name}
value={value}
placeholder={placeholder}
required={required}
disabled={disabled}
autofocus={autofocus}
autocomplete="off"
// Mobile: label the Enter key "search" and show the search keyboard.
enterkeyhint="search"
inputmode="search"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
aria-describedby={ariaDescribedby}
data-slot="active-search-input"
class={cn(inputBase, inputClass)}
{...hx}
/>
<span
id={indicatorId}
data-slot="active-search-indicator"
role="status"
aria-live="polite"
class={indicatorClass}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4 animate-spin"
aria-hidden="true"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span class="sr-only">{loadingLabel}</span>
</span>
{children}
</form>
)
}
1. Save the file
Copy active-search.html into templates/components/.
2. Use it
{% from "components/active-search.html" import active_search %}
{{ active_search(id="contacts", name="q", action="/contacts/search",
placeholder="Search contacts…",
hx_target="#contact-rows", hx_swap="innerHTML") }}
<table><tbody id="contact-rows"></tbody></table>View source
{# Active Search macro — shadcn-htmx, htmx v4 + Tailwind v4.
Debounced live-search box that filters an external results list/table as
you type, with an inline loading indicator and stale-request cancellation.
Submits as a normal GET search on Enter when JS is off (native <form action>).
Native-first: <form role="search"> wraps <input type="search">. The
`search` event fires on Enter + native clear, so it's added to hx-trigger.
repos/mdn/files/en-us/web/html/reference/elements/input/search/index.md
repos/mdn/files/en-us/web/api/htmlinputelement/search_event/index.md
htmx wiring:
hx-trigger="input changed delay:Nms, search" (debounce + clear/Enter)
repos/htmx/www/src/content/reference/01-attributes/06-hx-trigger.md
hx-sync="this:replace" (abort in-flight, use the latest request)
repos/htmx/www/src/content/reference/01-attributes/21-hx-sync.md
hx-indicator="#{id}-indicator" (.htmx-request fades the spinner in)
repos/htmx/www/src/content/reference/01-attributes/19-hx-indicator.md
Usage:
{% from "components/active-search.html" import active_search %}
{{ active_search(id="search", action="/search", placeholder="Search contacts…",
hx_get="/search", hx_target="#results", hx_swap="innerHTML") }}
<tbody id="results"></tbody>
#}
{% macro active_search(
id,
name="q",
placeholder="Search…",
value=none,
action=none,
method="get",
delay=300,
required=false,
disabled=false,
autofocus=false,
loading_label="Searching…",
aria_label=none,
aria_labelledby=none,
aria_describedby=none,
extra_class="",
input_class="",
**attrs
) %}
<form data-slot="active-search" role="search" class="relative w-full {{ extra_class }}"
{%- if action %} action="{{ action }}"{% endif %} method="{{ method }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
<input
type="search"
id="{{ id }}"
name="{{ name }}"
{%- if value is not none %} value="{{ value }}"{% endif %}
{%- if placeholder %} placeholder="{{ placeholder }}"{% endif %}
{%- if required %} required{% endif %}
{%- if disabled %} disabled{% endif %}
{%- if autofocus %} autofocus{% 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"
enterkeyhint="search"
inputmode="search"
data-slot="active-search-input"
{%- if "hx_get" not in attrs and action %} hx-get="{{ action }}"{% endif %}
{%- if "hx_trigger" not in attrs %} hx-trigger="input changed delay:{{ delay }}ms, search"{% endif %}
{%- if "hx_sync" not in attrs %} hx-sync="this:replace"{% endif %}
{%- if "hx_indicator" not in attrs %} hx-indicator="#{{ id }}-indicator"{% endif %}
class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent py-1 pr-9 pl-9 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 [&::-webkit-search-cancel-button]:hidden {{ input_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>
<span id="{{ id }}-indicator" data-slot="active-search-indicator" role="status" aria-live="polite"
class="htmx-indicator pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="size-4 animate-spin" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
<span class="sr-only">{{ loading_label }}</span>
</span>
{% if caller %}{{ caller() }}{% endif %}
</form>
{% endmacro %}
1. Save the file
Add active-search.tmpl alongside your templates.
2. Use it
{{template "active-search" (dict
"ID" "contacts" "Name" "q" "Action" "/contacts/search"
"Placeholder" "Search contacts…"
"HxTarget" "#contact-rows" "HxSwap" "innerHTML")}}
<table><tbody id="contact-rows"></tbody></table>View source
{{/*
Active Search template — shadcn-htmx, htmx v4 + Tailwind v4.
Debounced live-search box that filters an external results list/table as
you type, with an inline loading indicator and stale-request cancellation.
Submits as a normal GET search on Enter when JS is off (native <form action>).
Native-first: <form role="search"> wraps <input type="search">. The
`search` event fires on Enter + native clear, so it's added to hx-trigger.
repos/mdn/files/en-us/web/html/reference/elements/input/search/index.md
repos/mdn/files/en-us/web/api/htmlinputelement/search_event/index.md
htmx wiring:
hx-trigger="input changed delay:Nms, search" (debounce + clear/Enter)
repos/htmx/www/src/content/reference/01-attributes/06-hx-trigger.md
hx-sync="this:replace" (abort in-flight, use the latest request)
repos/htmx/www/src/content/reference/01-attributes/21-hx-sync.md
hx-indicator="#{ID}-indicator" (.htmx-request fades the spinner in)
repos/htmx/www/src/content/reference/01-attributes/19-hx-indicator.md
type ActiveSearchArgs struct {
ID, Name, Placeholder, Value, Action, Method string
Delay int // debounce ms; default 300
Required, Disabled, Autofocus bool
LoadingLabel string // sr-only loading text; default "Searching…"
AriaLabel, AriaLabelledby, AriaDescribedby string
// htmx target/swap for the results container.
HxGet, HxTarget, HxSwap string
}
Usage:
{{template "active-search" (dict "ID" "search" "Action" "/search"
"Placeholder" "Search contacts…" "HxGet" "/search"
"HxTarget" "#results" "HxSwap" "innerHTML")}}
*/}}
{{define "active-search"}}
{{- $name := or .Name "q" -}}
{{- $placeholder := or .Placeholder "Search…" -}}
{{- $method := or .Method "get" -}}
{{- $delay := or .Delay 300 -}}
{{- $loading := or .LoadingLabel "Searching…" -}}
{{- $hxGet := or .HxGet .Action -}}
<form data-slot="active-search" role="search" class="relative w-full"
{{if .Action}}action="{{.Action}}"{{end}} method="{{$method}}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
<input type="search" id="{{.ID}}" name="{{$name}}"
{{if .Value}}value="{{.Value}}"{{end}}
placeholder="{{$placeholder}}"
{{if .Required}}required{{end}} {{if .Disabled}}disabled{{end}}
{{if .Autofocus}}autofocus{{end}}
{{if .AriaLabel}}aria-label="{{.AriaLabel}}"{{end}}
{{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{end}}
{{if .AriaDescribedby}}aria-describedby="{{.AriaDescribedby}}"{{end}}
autocomplete="off" enterkeyhint="search" inputmode="search"
data-slot="active-search-input"
{{if $hxGet}}hx-get="{{$hxGet}}"{{end}}
hx-trigger="input changed delay:{{$delay}}ms, search"
hx-sync="this:replace"
hx-indicator="#{{.ID}}-indicator"
{{if .HxTarget}}hx-target="{{.HxTarget}}"{{end}}
{{if .HxSwap}}hx-swap="{{.HxSwap}}"{{end}}
class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent py-1 pr-9 pl-9 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 [&::-webkit-search-cancel-button]:hidden">
<span id="{{.ID}}-indicator" data-slot="active-search-indicator" role="status" aria-live="polite"
class="htmx-indicator pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="size-4 animate-spin" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
<span class="sr-only">{{$loading}}</span>
</span>
</form>
{{end}}
1. Save the file
Drop active_search.ex into lib/my_app_web/components/.
2. Use it
<.active_search id="contacts" name="q" action={~p"/contacts/search"}
placeholder="Search contacts…"
hx-target="#contact-rows" hx-swap="innerHTML" />
<table><tbody id="contact-rows"></tbody></table>View source
defmodule ShadcnHtmx.Components.ActiveSearch do
@moduledoc """
Active Search — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
A debounced live-search box that filters an external results list/table as
the user types, with an inline loading indicator and stale-request
cancellation. Submits as a normal GET search on Enter when JS is off
(native `<form action>`).
Native-first: `<form role="search">` wraps `<input type="search">`. The
`search` event fires on Enter + the native clear button, so it's added to
`hx-trigger`.
* repos/mdn/files/en-us/web/html/reference/elements/input/search/index.md
* repos/mdn/files/en-us/web/api/htmlinputelement/search_event/index.md
htmx wiring:
* `hx-trigger="input changed delay:Nms, search"` — debounce + clear/Enter.
repos/htmx/www/src/content/reference/01-attributes/06-hx-trigger.md
* `hx-sync="this:replace"` — abort the in-flight request, use the latest.
repos/htmx/www/src/content/reference/01-attributes/21-hx-sync.md
* `hx-indicator="#{id}-indicator"` — htmx adds `.htmx-request`; the spinner
fades in. repos/htmx/www/src/content/reference/01-attributes/19-hx-indicator.md
## Examples
<.active_search id="search" action={~p"/search"} placeholder="Search contacts…"
hx-get={~p"/search"} hx-target="#results" hx-swap="innerHTML" />
<tbody id="results"></tbody>
"""
use Phoenix.Component
attr :id, :string, required: true
attr :name, :string, default: "q"
attr :placeholder, :string, default: "Search…"
attr :value, :string, default: nil
attr :action, :string, default: nil
attr :method, :string, default: "get"
attr :delay, :integer, default: 300
attr :required, :boolean, default: false
attr :disabled, :boolean, default: false
attr :autofocus, :boolean, default: false
attr :loading_label, :string, default: "Searching…"
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-sync hx-target hx-swap hx-indicator hx-vals hx-include)
def active_search(assigns) do
~H"""
<form
data-slot="active-search"
role="search"
class={["relative w-full", @class]}
action={@action}
method={@method}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
type="search"
id={@id}
name={@name}
value={@value}
placeholder={@placeholder}
required={@required}
disabled={@disabled}
autofocus={@autofocus}
autocomplete="off"
enterkeyhint="search"
inputmode="search"
aria-label={assigns[:"aria-label"]}
aria-labelledby={assigns[:"aria-labelledby"]}
aria-describedby={assigns[:"aria-describedby"]}
data-slot="active-search-input"
hx-get={@action}
hx-trigger={"input changed delay:#{@delay}ms, search"}
hx-sync="this:replace"
hx-indicator={"##{@id}-indicator"}
class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent py-1 pr-9 pl-9 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 [&::-webkit-search-cancel-button]:hidden"
{@rest}
/>
<span
id={"#{@id}-indicator"}
data-slot="active-search-indicator"
role="status"
aria-live="polite"
class="htmx-indicator pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-4 animate-spin"
aria-hidden="true"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span class="sr-only">{@loading_label}</span>
</span>
</form>
"""
end
end
1. Save the file
Paste the markup; relies only on theme tokens.
2. Use it
<form role="search" action="/contacts/search" method="get" class="relative w-full">
<input type="search" id="contacts" name="q" placeholder="Search contacts…"
hx-get="/contacts/search"
hx-trigger="input changed delay:300ms, search"
hx-sync="this:replace"
hx-indicator="#contacts-indicator"
hx-target="#contact-rows" hx-swap="innerHTML" class="…">
<span id="contacts-indicator" class="htmx-indicator …" role="status">…</span>
</form>
<table><tbody id="contact-rows"></tbody></table>View source
<!--
shadcn-htmx — raw HTML active-search snippet.
Debounced live-search box that filters an external results list/table as
you type, with an inline loading indicator and stale-request cancellation.
Submits as a normal GET search on Enter when JS is off (native <form action>).
Native-first: <form role="search"> wraps <input type="search">. The
`search` event fires on Enter + the native clear button, so it's in
hx-trigger alongside the debounced `input` trigger.
- hx-trigger="input changed delay:300ms, search" (debounce + clear/Enter)
- hx-sync="this:replace" (abort in-flight, use the latest request)
- hx-indicator="#search-indicator" (.htmx-request fades the spinner in)
Wire hx-target at your results container; the server returns the rows/list.
Relies only on theme tokens — no app CSS beyond Tailwind + the htmx-indicator
class (htmx ships its default opacity transition for it).
-->
<form data-slot="active-search" role="search" class="relative w-full"
action="/search" method="get">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
<input type="search" id="search" name="q"
placeholder="Search contacts…"
autocomplete="off" enterkeyhint="search" inputmode="search"
data-slot="active-search-input"
hx-get="/search"
hx-trigger="input changed delay:300ms, search"
hx-sync="this:replace"
hx-indicator="#search-indicator"
hx-target="#results"
hx-swap="innerHTML"
class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent py-1 pr-9 pl-9 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 [&::-webkit-search-cancel-button]:hidden">
<span id="search-indicator" data-slot="active-search-indicator" role="status" aria-live="polite"
class="htmx-indicator pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="size-4 animate-spin" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
</svg>
<span class="sr-only">Searching…</span>
</span>
</form>
<!-- Results container the search targets. Server returns the rows on each query. -->
<tbody id="results"></tbody>
Examples
Filter a table as you type
Each keystroke (debounced 300ms) fetches matching contacts; the server returns <tr> rows swapped into the table body. The spinner fades in while the request is in flight.
The whole control is a native <form role="search"> wrapping <input type="search">. With JS off, Enter submits a plain GET to action — a shareable, bookmarkable search URL. With htmx on, hx-trigger="input changed delay:300ms, search" debounces typing and the search event re-runs the filter on Enter and when the field is cleared. No custom JS of our own.
| First name | Last name | |
|---|---|---|
| Venus | Grimes | [email protected] |
| Fletcher | Owen | [email protected] |
| William | Hale | [email protected] |
| TaShya | Cash | [email protected] |
| Jakeem | Walker | [email protected] |
| Malcolm | Trujillo | [email protected] |
| Wynne | Rice | [email protected] |
| Evangeline | Mcclain | [email protected] |
| Bruce | Emerson | [email protected] |
| Mufutau | Berg | [email protected] |
<ActiveSearch id="contacts" name="q"
action="/contacts/search"
placeholder="Search contacts…"
hx-target="#contact-rows" hx-swap="innerHTML" />
<table><tbody id="contact-rows">…</tbody></table>
{/* Server returns <tr> rows for the query. */}{{ active_search(id="contacts", action="/contacts/search",
placeholder="Search contacts…",
hx_target="#contact-rows", hx_swap="innerHTML") }}
<table><tbody id="contact-rows"></tbody></table>{{template "active-search" (dict "ID" "contacts" "Action" "/contacts/search"
"Placeholder" "Search contacts…"
"HxTarget" "#contact-rows" "HxSwap" "innerHTML")}}
<table><tbody id="contact-rows"></tbody></table><.active_search id="contacts" action={~p"/contacts/search"}
placeholder="Search contacts…"
hx-target="#contact-rows" hx-swap="innerHTML" />
<table><tbody id="contact-rows"></tbody></table><div class="grid w-full max-w-md gap-3">
<label for="ex-as-contacts" 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">Search contacts</label>
<form data-slot="active-search" role="search" class="relative w-full" action="/docs/active-search/search" method="get">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true">
<circle cx="11" cy="11" r="8">
</circle>
<path d="m21 21-4.3-4.3">
</path>
</svg>
<input type="search" id="ex-as-contacts" name="q" placeholder="Try "wa" or "example.com"…" autocomplete="off" enterkeyhint="search" inputmode="search" data-slot="active-search-input" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent py-1 pr-9 pl-9 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 [&::-webkit-search-cancel-button]:hidden" hx-get="/docs/active-search/search" hx-trigger="input changed delay:300ms, search" hx-sync="this:replace" hx-indicator="#ex-as-contacts-indicator" hx-target="#ex-as-rows" hx-swap="innerHTML"/>
<span id="ex-as-contacts-indicator" data-slot="active-search-indicator" role="status" aria-live="polite" class="htmx-indicator pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 animate-spin" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-6.219-8.56">
</path>
</svg>
<span class="sr-only">Searching…</span>
</span>
</form>
<div class="relative w-full overflow-auto">
<table data-slot="table" class="w-full caption-bottom text-sm">
<thead data-slot="table-header" class="[&_tr]:border-b">
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<th scope="col" data-slot="table-head" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">First name</th>
<th scope="col" data-slot="table-head" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Last name</th>
<th scope="col" data-slot="table-head" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Email</th>
</tr>
</thead>
<tbody id="ex-as-rows" data-slot="table-body" class="[&_tr:last-child]:border-0">
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Venus</td>
<td data-slot="table-cell" class="p-2 align-middle">Grimes</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Fletcher</td>
<td data-slot="table-cell" class="p-2 align-middle">Owen</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">William</td>
<td data-slot="table-cell" class="p-2 align-middle">Hale</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">TaShya</td>
<td data-slot="table-cell" class="p-2 align-middle">Cash</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Jakeem</td>
<td data-slot="table-cell" class="p-2 align-middle">Walker</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Malcolm</td>
<td data-slot="table-cell" class="p-2 align-middle">Trujillo</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Wynne</td>
<td data-slot="table-cell" class="p-2 align-middle">Rice</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Evangeline</td>
<td data-slot="table-cell" class="p-2 align-middle">Mcclain</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Bruce</td>
<td data-slot="table-cell" class="p-2 align-middle">Emerson</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Mufutau</td>
<td data-slot="table-cell" class="p-2 align-middle">Berg</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
</tbody>
</table>
</div>
</div>Request cancellation (hx-sync)
When you type faster than the server responds, in-flight requests would otherwise race. hx-sync="this:replace" aborts the previous request so only the latest response lands.
The debounce reduces requests, but once one is in flight a new keystroke would start a second. If the first response arrives last, it clobbers the newer results — a classic search race. The component sets hx-sync="this:replace" by default, which aborts the in-flight request and replaces it with the latest one. This demo endpoint adds a small artificial delay so the cancellation is observable in the network panel.
| First name | Last name | |
|---|---|---|
| Venus | Grimes | [email protected] |
| Fletcher | Owen | [email protected] |
| William | Hale | [email protected] |
| TaShya | Cash | [email protected] |
| Jakeem | Walker | [email protected] |
| Malcolm | Trujillo | [email protected] |
| Wynne | Rice | [email protected] |
| Evangeline | Mcclain | [email protected] |
| Bruce | Emerson | [email protected] |
| Mufutau | Berg | [email protected] |
// hx-sync="this:replace" is the component default — no extra props.
<ActiveSearch id="contacts" action="/contacts/search"
hx-target="#contact-rows" hx-swap="innerHTML" />{# hx-sync="this:replace" is applied by the macro automatically #}
{{ active_search(id="contacts", action="/contacts/search",
hx_target="#contact-rows", hx_swap="innerHTML") }}{{/* hx-sync="this:replace" is built into the template */}}
{{template "active-search" (dict "ID" "contacts" "Action" "/contacts/search"
"HxTarget" "#contact-rows" "HxSwap" "innerHTML")}}<%# hx-sync="this:replace" is applied by the component %>
<.active_search id="contacts" action={~p"/contacts/search"}
hx-target="#contact-rows" hx-swap="innerHTML" /><div class="grid w-full max-w-md gap-3">
<label for="ex-as-slow" 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">Search contacts (slow server)</label>
<form data-slot="active-search" role="search" class="relative w-full" action="/docs/active-search/slow-search" method="get">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden="true">
<circle cx="11" cy="11" r="8">
</circle>
<path d="m21 21-4.3-4.3">
</path>
</svg>
<input type="search" id="ex-as-slow" name="q" placeholder="Type fast — watch the network panel…" autocomplete="off" enterkeyhint="search" inputmode="search" data-slot="active-search-input" class="flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent py-1 pr-9 pl-9 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 [&::-webkit-search-cancel-button]:hidden" hx-get="/docs/active-search/slow-search" hx-trigger="input changed delay:300ms, search" hx-sync="this:replace" hx-indicator="#ex-as-slow-indicator" hx-target="#ex-as-slow-rows" hx-swap="innerHTML"/>
<span id="ex-as-slow-indicator" data-slot="active-search-indicator" role="status" aria-live="polite" class="htmx-indicator pointer-events-none absolute top-1/2 right-3 size-4 -translate-y-1/2 text-muted-foreground">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 animate-spin" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-6.219-8.56">
</path>
</svg>
<span class="sr-only">Searching…</span>
</span>
</form>
<div class="relative w-full overflow-auto">
<table data-slot="table" class="w-full caption-bottom text-sm">
<thead data-slot="table-header" class="[&_tr]:border-b">
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<th scope="col" data-slot="table-head" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">First name</th>
<th scope="col" data-slot="table-head" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Last name</th>
<th scope="col" data-slot="table-head" class="h-10 px-2 text-left align-middle font-medium text-muted-foreground">Email</th>
</tr>
</thead>
<tbody id="ex-as-slow-rows" data-slot="table-body" class="[&_tr:last-child]:border-0">
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Venus</td>
<td data-slot="table-cell" class="p-2 align-middle">Grimes</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Fletcher</td>
<td data-slot="table-cell" class="p-2 align-middle">Owen</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">William</td>
<td data-slot="table-cell" class="p-2 align-middle">Hale</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">TaShya</td>
<td data-slot="table-cell" class="p-2 align-middle">Cash</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Jakeem</td>
<td data-slot="table-cell" class="p-2 align-middle">Walker</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Malcolm</td>
<td data-slot="table-cell" class="p-2 align-middle">Trujillo</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Wynne</td>
<td data-slot="table-cell" class="p-2 align-middle">Rice</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Evangeline</td>
<td data-slot="table-cell" class="p-2 align-middle">Mcclain</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Bruce</td>
<td data-slot="table-cell" class="p-2 align-middle">Emerson</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
<tr data-slot="table-row" class="border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted">
<td data-slot="table-cell" class="p-2 align-middle font-medium">Mufutau</td>
<td data-slot="table-cell" class="p-2 align-middle">Berg</td>
<td data-slot="table-cell" class="p-2 align-middle text-muted-foreground">[email protected]</td>
</tr>
</tbody>
</table>
</div>
</div>Further reading
API Reference
Active Search
| Prop | Type | Default | Description |
|---|---|---|---|
id* | string | — | Input id. The loading indicator gets id="{id}-indicator" and hx-indicator points at it. |
name | string | "q" | Form field name submitted on Enter and sent as the query param to the server. |
action | string | — | URL the native <form> navigates to on Enter when JS is off, and the htmx GET URL when hx-get isn't passed explicitly.MDN<form action> |
method | "get"|"post" | "get" | Form method for the no-JS fallback. GET keeps the search idempotent and the result URL shareable. |
delay | number | 300 | Debounce window in ms for the input trigger (hx-trigger="input changed delay:{delay}ms, search").htmxhx-trigger delay |
placeholder | string | "Search…" | Placeholder hint shown when the field is empty. |
value | string | — | Initial value (e.g. to reflect a server-rendered query). |
required | boolean | false | Native HTML required constraint for the no-JS form submit. |
disabled | boolean | false | Disable the input — skipped from tab order, no requests. |
autofocus | boolean | false | Focus the search field on initial page load (one per document). |
loadingLabel | string | "Searching…" | Visually-hidden text inside the role="status" indicator, announced to assistive tech while a request is in flight.MDNrole=status |
inputClass | string | — | Extra Tailwind classes appended to the <input> (the root <form> uses 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