shshadcn-htmx

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

2. Use it

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

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

components/active-search.tmpl
{{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
components/active-search.tmpl
{{/*
  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

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

snippets/active-search.html
<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
snippets/active-search.html
<!--
  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 nameLast nameEmail
VenusGrimes[email protected]
FletcherOwen[email protected]
WilliamHale[email protected]
TaShyaCash[email protected]
JakeemWalker[email protected]
MalcolmTrujillo[email protected]
WynneRice[email protected]
EvangelineMcclain[email protected]
BruceEmerson[email protected]
MufutauBerg[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 &quot;wa&quot; or &quot;example.com&quot;…" 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 [&amp;::-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="[&amp;_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="[&amp;_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 nameLast nameEmail
VenusGrimes[email protected]
FletcherOwen[email protected]
WilliamHale[email protected]
TaShyaCash[email protected]
JakeemWalker[email protected]
MalcolmTrujillo[email protected]
WynneRice[email protected]
EvangelineMcclain[email protected]
BruceEmerson[email protected]
MufutauBerg[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 [&amp;::-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="[&amp;_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="[&amp;_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>

API Reference

Active Search

PropTypeDefaultDescription
id*string
Input id. The loading indicator gets id="{id}-indicator" and hx-indicator points at it.
namestring"q"
Form field name submitted on Enter and sent as the query param to the server.
actionstring
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.
delaynumber300
Debounce window in ms for the input trigger (hx-trigger="input changed delay:{delay}ms, search").htmxhx-trigger delay
placeholderstring"Search…"
Placeholder hint shown when the field is empty.
valuestring
Initial value (e.g. to reflect a server-rendered query).
requiredbooleanfalse
Native HTML required constraint for the no-JS form submit.
disabledbooleanfalse
Disable the input — skipped from tab order, no requests.
autofocusbooleanfalse
Focus the search field on initial page load (one per document).
loadingLabelstring"Searching…"
Visually-hidden text inside the role="status" indicator, announced to assistive tech while a request is in flight.MDNrole=status
inputClassstring
Extra Tailwind classes appended to the <input> (the root <form> uses 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