Filter popover
filter-popover pairs the native popover with an htmx form that
filters a results region. When the user clicks Apply, htmx swaps
the results and the installClosePopover behavior dismisses the
popover.
Live demo
Section titled “Live demo”Open Filter, pick a status or type part of a name, and hit
Apply — htmx GETs a demo endpoint under
api/recipes/filter-popover/ (a real app would use its own URL such
as /items), swaps the list, and because the response is a 2xx the
installClosePopover behavior dismisses the popover for you.
Basic usage
Section titled “Basic usage”<button class="hc-button" type="button" popovertarget="status-filter"> Status</button>
<div id="status-filter" class="hc-popover" popover> <form data-hx-get="/items" data-hx-include="this" data-hx-target="#items-tbody" data-hx-swap="innerHTML" data-hx-push-url="true" data-hc-close-popover-on-success>
<fieldset> <legend>Status</legend> <label><input type="checkbox" name="status" value="active"> Active</label> <label><input type="checkbox" name="status" value="pending"> Pending</label> <label><input type="checkbox" name="status" value="failed"> Failed</label> </fieldset>
<div class="hc-toolbar"> <button class="hc-button" data-size="sm" type="reset">Reset</button> <span data-hc-spacer="true"></span> <button class="hc-button" data-size="sm" data-variant="primary" type="submit">Apply</button> </div> </form></div>
<table class="hc-table"> <thead>…</thead> <tbody id="items-tbody" data-hx-get="/items" data-hx-trigger="load"></tbody></table>What happens
Section titled “What happens”- The user clicks Status → browser opens the popover
(
popovertarget+popover=auto). - The user toggles checkboxes and clicks Apply.
- The form has
data-hc-close-popover-on-successanddata-hx-get="/items". htmx submits a GET with the form data and swaps the response into#items-tbody. htmx:afterRequestfires. TheinstallClosePopoverbehavior sees the opt-in attribute, the request was successful, and callshidePopover()on the closest[popover].data-hx-push-url="true"updates the URL so the filter survives page reloads and is shareable.
Cancel by simply clicking outside (popover’s native light-dismiss) or pressing Escape — no JavaScript hook needed.
Server response contract
Section titled “Server response contract”GET /items?status=active&status=pending returns the table body
fragment:
<tr id="item-12">…</tr><tr id="item-15">…</tr>…For the empty state, return explicit empty-state markup so the user knows the filter ran:
<tr> <td colspan="3"> <p class="hc-field__message">No items match this filter.</p> </td></tr>For toast feedback on the apply itself (rarely needed for filters),
include an HX-Trigger: {"hc:toast":{…}} header.
| Request | Response |
|---|---|
GET /items?status=active&status=pending (Apply) | 200 + the #items-tbody rows fragment — the popover closes |
| Filter matches nothing | 200 + an explicit empty-state row |
| Failure | non-2xx — not swapped; the table keeps its rows and the popover stays open |
On failure
Section titled “On failure”htmx ≥ 2 does not swap non-2xx responses, so the table keeps its
previous rows — and installClosePopover only closes on success, so
the popover stays open for the user to retry. To notify explicitly,
add an HX-Trigger: {"hc:toast":{…}} header to the error response
(see the toast recipe).
Accessibility
Section titled “Accessibility”- The popover is opened by a real
<button>withpopovertarget. The browser handles focus, light-dismiss, and Escape. - A popover is not automatically a menu. Setting
role="menu"on this filter form would be incorrect — it is a form. Leave the default role. - Provide a visible
<legend>for each fieldset. Checkbox-style filters witharia-labelinstead of legends are harder to scan. aria-currenton the trigger (e.g. “Status: 2 selected”) is a nice affordance once the popover closes; render the count server-side from the filter state.
Progressive enhancement
Section titled “Progressive enhancement”- Without JavaScript, the popover is just a plain
<div>— visible in the page. Add[popover]:not(:popover-open) { display: none; }in your reset, and consider a non-JS fallback: a full-page filter form at a separate URL. - Without
hc.behaviors.js, the htmx swap still happens but the popover stays open. That is benign — the user can dismiss it manually.
Related
Section titled “Related”- Popover component
- Live search recipe — the same htmx pattern without a popover wrapper.
- Toast recipe — for “Saved filter” feedback.