Components
Lazy Load
A deferred-content container. It renders a placeholder, then fetches its own contents after first paint via hx-trigger="load" and swaps them in. A reserved minimum height holds the box steady so the swap doesn't shift the page (CLS). Pair it with Skeleton for slow dashboard panels or per-tab content.
Installation
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/lazy-load.json2. Use it
import { LazyLoad } from "@/components/ui/lazy-load"
{/* Fetches its contents after first paint; reserve holds the box steady. */}
<LazyLoad src="/dashboard/sales" reserve="12rem" ariaLabel="Loading sales report" />
{/* Defer until scrolled into view. */}
<LazyLoad src="/comments" trigger="revealed" reserve="8rem" ariaLabel="Loading comments" />Or copy the source manually
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Lazy Load — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A deferred-content container. It renders a placeholder immediately, then
// fetches its own contents after the page paints and swaps them in. The
// placeholder reserves vertical space so the swap does not push the page
// around (Cumulative Layout Shift). Pair it with <Skeleton> for slow
// dashboard panels or per-tab content that you don't want to block first
// paint on.
//
// shadcn/ui has no "lazy load" widget — it is a hypermedia loading pattern,
// not a Radix primitive, so there is no React source of truth to mirror. We
// build it straight from the htmx v4 lazy-load pattern and the platform docs:
// repos/htmx/www/src/content/patterns/01-loading/03-lazy-load.md
// (the canonical pattern: a placeholder div with hx-get + hx-trigger="load";
// htmx swaps the response in when it arrives. The "Layout shift" note
// says to reserve space with min-height to protect the Lighthouse/CLS
// score — that is exactly what `reserve`/min-height does here. The
// "Infinite loops" note warns against echoing hx-trigger="load" in the
// response: our default hx-swap="innerHTML" keeps the trigger host in
// place, so the response body must NOT repeat the trigger.)
// repos/htmx/www/src/content/reference/01-attributes/06-hx-trigger.md
// (verified v4: synthetic `load` fires when the element enters the DOM —
// "Useful for lazy-loading content"; `revealed` fires when it scrolls
// into the viewport; use `intersect once` instead when the element lives
// inside an `overflow-y: scroll` container.)
// repos/htmx/www/src/content/reference/01-attributes/07-hx-swap.md
// (verified v4: `innerHTML` — the default — replaces the *contents* of the
// target, leaving our reserved-space wrapper in the DOM; `outerHTML`
// replaces the wrapper wholesale, for when the response brings its own
// box. We default to innerHTML so the reserved height survives the swap.)
// repos/htmx/www/src/content/reference/01-attributes/01-hx-get.md
// (the request the container issues for its own contents.)
// repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-busy/index.md
// (verified: aria-busy="true" on a region tells AT "this content is still
// being modified — wait before announcing". The role="status" live region
// announces once the busy content settles. Both ride away with the
// innerHTML swap because they live on the wrapper, which is fine — htmx
// flips nothing for us, so we keep them simple and static.)
// repos/mdn/files/en-us/web/api/intersection_observer_api/index.md
// (the platform API htmx's revealed/intersect triggers are built on —
// "implementing infinite-scrolling websites … as you scroll".)
//
// Zero JS of our own: htmx owns the request and the IntersectionObserver; the
// in-flight state is the placeholder we render. No emulation of any platform
// feature — the trigger IS htmx's `load` event / the platform's
// IntersectionObserver.
export type LazyLoadTrigger = "load" | "revealed" | "intersect"
export type LazyLoadSwap = "innerHTML" | "outerHTML"
// Maps our trigger prop to the literal hx-trigger value. `intersect once`
// fires a single time when the element first crosses the viewport (the
// overflow-container form); `revealed` is the page-viewport form; `load`
// fires immediately on insertion.
const TRIGGER_MAP: Record<LazyLoadTrigger, string> = {
load: "load",
revealed: "revealed",
intersect: "intersect once",
}
// The reserved-space wrapper. min-h keeps a stable box so the swap doesn't
// shift the page; centred so the default placeholder/spinner sits middle.
const rootClasses =
"flex w-full items-center justify-center text-sm text-muted-foreground"
// Default placeholder: a muted inline spinner + caption. It is the visible
// "loading" state until the response swaps in. Pass children to override it
// (e.g. a composed <Skeleton> silhouette).
function Spinner() {
return (
<span
class="size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground"
aria-hidden="true"
/>
)
}
type LazyLoadProps = PropsWithChildren<{
// URL to fetch this container's contents from. Sets hx-get for you.
src?: string
// When the fetch fires. "load" → immediately on insertion (deferred but
// eager); "revealed" → when scrolled into the page viewport; "intersect"
// → first viewport crossing inside an overflow-y:scroll container.
trigger?: LazyLoadTrigger
// How the response lands. "innerHTML" (default) replaces the contents and
// keeps this reserved-space wrapper; "outerHTML" replaces the wrapper.
swap?: LazyLoadSwap
// Reserved minimum height (any CSS length, e.g. "12rem" or "200px"). Sets
// min-height inline so the box holds its size before content arrives —
// prevents layout shift (CLS).
reserve?: string
// Accessible name for the loading region ("Loading sales report").
ariaLabel?: string
class?: ClassValue
id?: string
// htmx / data / aria attributes ride onto the container. Forwarded so call
// sites can override hx-target, add hx-indicator, hx-vals, hx-swap timing,
// etc.
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
export function LazyLoad(props: LazyLoadProps) {
const {
src,
trigger = "load",
swap = "innerHTML",
reserve,
ariaLabel = "Loading",
class: className,
id,
children,
...rest
} = props as any
return (
<div
id={id}
data-slot="lazy-load"
data-trigger={trigger}
role="status"
aria-busy="true"
aria-label={ariaLabel}
hx-get={src}
hx-trigger={TRIGGER_MAP[trigger as LazyLoadTrigger]}
hx-swap={swap}
style={reserve ? `min-height:${reserve}` : undefined}
class={cn(rootClasses, className)}
{...rest}
>
{children ?? (
<span class="flex items-center gap-2">
<Spinner />
Loading…
</span>
)}
</div>
)
}
1. Save the file
Copy lazy-load.html into templates/components/.
2. Use it
{% from "components/lazy-load.html" import lazy_load %}
{{ lazy_load(src="/dashboard/sales", reserve="12rem", aria_label="Loading sales report") }}
{{ lazy_load(src="/comments", trigger="revealed", reserve="8rem", aria_label="Loading comments") }}View source
{# Lazy Load macro — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/lazy-load.tsx.
A deferred-content container. It renders a placeholder immediately, then
fetches its own contents after the page paints (hx-get + hx-trigger="load")
and swaps them in. `reserve` sets min-height so the swap does not shift the
page (CLS). trigger="load" fires on insertion; "revealed" fires on scroll
into the page viewport; "intersect" fires once inside an overflow-y:scroll
container. swap="innerHTML" (default) keeps this wrapper; "outerHTML"
replaces it. Default hx-swap="innerHTML" keeps the trigger host, so the
server response must NOT repeat hx-trigger="load" (avoids an infinite loop).
Sources cited in lazy-load.tsx:
repos/htmx/.../patterns/01-loading/03-lazy-load.md
repos/htmx/.../reference/01-attributes/{01-hx-get,06-hx-trigger,07-hx-swap}.md
repos/mdn/.../web/accessibility/aria/reference/attributes/aria-busy/index.md
repos/mdn/.../web/api/intersection_observer_api/index.md
Usage:
{% from "components/lazy-load.html" import lazy_load %}
{{ lazy_load(src="/dashboard/sales", reserve="12rem", aria_label="Loading sales") }}
{{ lazy_load(src="/comments", trigger="revealed", reserve="8rem") }} #}
{% macro lazy_load(src=none, trigger="load", swap="innerHTML", reserve=none, aria_label="Loading", id=none, extra_class="", **attrs) %}
<div
{%- if id %} id="{{ id }}"{% endif %}
data-slot="lazy-load" data-trigger="{{ trigger }}"
role="status" aria-busy="true" aria-label="{{ aria_label }}"
{% if src %}hx-get="{{ src }}"{% endif %}
hx-trigger="{{ 'intersect once' if trigger == 'intersect' else 'revealed' if trigger == 'revealed' else 'load' }}" hx-swap="{{ swap }}"
{% if reserve %}style="min-height:{{ reserve }}"{% endif %}
class="flex w-full items-center justify-center text-sm text-muted-foreground {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{% if caller %}{{ caller() }}{% else %}<span class="flex items-center gap-2"><span class="size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true"></span>Loading…</span>{% endif %}</div>
{% endmacro %}
1. Save the file
Add lazy-load.tmpl alongside your templates.
2. Use it
{{template "lazy_load" (dict "Src" "/dashboard/sales" "Reserve" "12rem" "AriaLabel" "Loading sales report")}}
{{template "lazy_load" (dict "Src" "/comments" "Trigger" "revealed" "Reserve" "8rem" "AriaLabel" "Loading comments")}}View source
{{/* Lazy Load template — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/lazy-load.tsx.
A deferred-content container. It renders a placeholder immediately, then
fetches its own contents after the page paints (hx-get + hx-trigger="load")
and swaps them in. Reserve sets min-height so the swap does not shift the
page (CLS). Trigger "load" fires on insertion; "revealed" fires on scroll
into the page viewport; "intersect" fires once inside an overflow-y:scroll
container. Swap "innerHTML" (default) keeps this wrapper; "outerHTML"
replaces it. The default hx-swap="innerHTML" keeps the trigger host, so
the server response must NOT repeat hx-trigger="load" (infinite loop).
Sources cited in lazy-load.tsx:
repos/htmx/.../patterns/01-loading/03-lazy-load.md
repos/htmx/.../reference/01-attributes/{01-hx-get,06-hx-trigger,07-hx-swap}.md
repos/mdn/.../web/accessibility/aria/reference/attributes/aria-busy/index.md
repos/mdn/.../web/api/intersection_observer_api/index.md
Usage:
{{template "lazy_load" (dict "Src" "/dashboard/sales" "Reserve" "12rem" "AriaLabel" "Loading sales")}}
{{template "lazy_load" (dict "Src" "/comments" "Trigger" "revealed" "Reserve" "8rem")}} */}}
{{define "lazy_load"}}
{{- $trigger := or .Trigger "load" -}}
{{- $swap := or .Swap "innerHTML" -}}
<div {{if .ID}}id="{{.ID}}"{{end}}
data-slot="lazy-load" data-trigger="{{$trigger}}"
role="status" aria-busy="true" aria-label="{{or .AriaLabel "Loading"}}"
{{if .Src}}hx-get="{{.Src}}"{{end}}
hx-trigger="{{if eq $trigger "intersect"}}intersect once{{else if eq $trigger "revealed"}}revealed{{else}}load{{end}}" hx-swap="{{$swap}}"
{{if .Reserve}}style="min-height:{{.Reserve}}"{{end}}
class="flex w-full items-center justify-center text-sm text-muted-foreground {{.Class}}">{{if .Body}}{{htmlSafe .Body}}{{else}}<span class="flex items-center gap-2"><span class="size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true"></span>Loading…</span>{{end}}</div>
{{end}}
1. Save the file
Drop lazy_load.ex into lib/my_app_web/components/.
2. Use it
<.lazy_load src={~p"/dashboard/sales"} reserve="12rem" aria-label="Loading sales report" />
<.lazy_load src={~p"/comments"} trigger="revealed" reserve="8rem" aria-label="Loading comments" />View source
defmodule ShadcnHtmx.Components.LazyLoad do
@moduledoc """
Lazy Load — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
A deferred-content container. It renders a placeholder immediately, then
fetches its own contents after the page paints (`hx-get` + `hx-trigger="load"`)
and swaps them in. `reserve` sets `min-height` so the swap does not shift the
page (Cumulative Layout Shift). Pair it with `<.skeleton>` for slow dashboard
panels or per-tab content.
* `trigger="load"` — fires immediately when the element enters the DOM.
* `trigger="revealed"` — fires when it scrolls into the page viewport.
* `trigger="intersect"` — fires once inside an `overflow-y: scroll`
container (`intersect once`).
* `swap="innerHTML"` (default) keeps this reserved-space wrapper; the server
response must NOT repeat `hx-trigger="load"` or it loops. `swap="outerHTML"`
replaces the wrapper wholesale.
Sources (read, not copied) — see registry/ui/lazy-load.tsx:
repos/htmx/.../patterns/01-loading/03-lazy-load.md
repos/htmx/.../reference/01-attributes/{01-hx-get,06-hx-trigger,07-hx-swap}.md
repos/mdn/.../web/accessibility/aria/reference/attributes/aria-busy/index.md
repos/mdn/.../web/api/intersection_observer_api/index.md
## Examples
<.lazy_load src={~p"/dashboard/sales"} reserve="12rem" aria-label="Loading sales" />
<.lazy_load src={~p"/comments"} trigger="revealed" reserve="8rem" />
"""
use Phoenix.Component
attr :src, :string, default: nil
attr :trigger, :string, default: "load", values: ~w(load revealed intersect)
attr :swap, :string, default: "innerHTML", values: ~w(innerHTML outerHTML)
attr :reserve, :string, default: nil
attr :"aria-label", :string, default: "Loading"
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block
def lazy_load(assigns) do
~H"""
<div
data-slot="lazy-load"
data-trigger={@trigger}
role="status"
aria-busy="true"
aria-label={assigns[:"aria-label"]}
hx-get={@src}
hx-trigger={
case @trigger do
"intersect" -> "intersect once"
"revealed" -> "revealed"
_ -> "load"
end
}
hx-swap={@swap}
style={@reserve && "min-height:#{@reserve}"}
class={["flex w-full items-center justify-center text-sm text-muted-foreground", @class]}
{@rest}
>
<%= if @inner_block != [] do %>
{render_slot(@inner_block)}
<% else %>
<span class="flex items-center gap-2">
<span
class="size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground"
aria-hidden="true"
/>
Loading…
</span>
<% end %>
</div>
"""
end
end
1. Save the file
Paste the markup; relies only on theme tokens.
2. Use it
<div data-slot="lazy-load" data-trigger="load"
role="status" aria-busy="true" aria-label="Loading sales report"
hx-get="/dashboard/sales" hx-trigger="load" hx-swap="innerHTML"
style="min-height:12rem"
class="flex w-full items-center justify-center text-sm text-muted-foreground">
<span class="flex items-center gap-2">
<span class="size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true"></span>
Loading…
</span>
</div>View source
<!--
shadcn-htmx — raw HTML lazy-load snippet.
A deferred-content container. It renders a placeholder immediately, then
fetches its own contents after the page paints (hx-get + hx-trigger="load")
and swaps them in. The inline style="min-height:…" reserves vertical space
so the swap does not shift the page (Cumulative Layout Shift).
Triggers:
- hx-trigger="load" → fires immediately on insertion (deferred,
but eager — the default).
- hx-trigger="revealed" → fires when scrolled into the page viewport.
- hx-trigger="intersect once"→ fires once inside an overflow-y:scroll box.
Swap: hx-swap="innerHTML" (default) replaces the contents and keeps this
reserved-space wrapper in the DOM — so the server response must NOT repeat
hx-trigger="load" or it loops (see the htmx "Infinite loops" note). Use
hx-swap="outerHTML" if the response brings its own container.
role="status" + aria-busy="true" tell assistive tech the region is still
loading. Relies only on the theme tokens in styles.css — no JS of its own.
Sources (read, not copied):
repos/htmx/.../patterns/01-loading/03-lazy-load.md
repos/htmx/.../reference/01-attributes/{01-hx-get,06-hx-trigger,07-hx-swap}.md
repos/mdn/.../web/accessibility/aria/reference/attributes/aria-busy/index.md
repos/mdn/.../web/api/intersection_observer_api/index.md
-->
<!-- On insertion it GETs /dashboard/sales and replaces its own contents with
the response. The reserved 12rem height holds the box steady until then. -->
<div data-slot="lazy-load" data-trigger="load"
role="status" aria-busy="true" aria-label="Loading sales report"
hx-get="/dashboard/sales" hx-trigger="load" hx-swap="innerHTML"
style="min-height:12rem"
class="flex w-full items-center justify-center text-sm text-muted-foreground">
<span class="flex items-center gap-2">
<span class="size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true"></span>
Loading…
</span>
</div>
Examples
Deferred panel
The container fetches its contents the moment it enters the DOM (hx-trigger="load"), and swaps the response into itself (hx-swap="innerHTML"). The reserved 12rem height holds the box steady so the page doesn't jump when the panel arrives.
This is the htmx lazy-load pattern: render fast, fill in the slow bits afterward. The wrapper carries role="status" + aria-busy="true", so assistive tech knows the region is still loading. Because the default swap is innerHTML, the response is the panel's contents — it must not repeat the trigger, or the request would loop forever.
<LazyLoad
src="/dashboard/sales"
reserve="12rem"
ariaLabel="Loading sales report"
/>
// Server GET /dashboard/sales returns the panel's contents.
// It must NOT include hx-trigger="load" (innerHTML swap → loop).{{ lazy_load(src="/dashboard/sales", reserve="12rem", aria_label="Loading sales report") }}{{template "lazy_load" (dict "Src" "/dashboard/sales" "Reserve" "12rem" "AriaLabel" "Loading sales report")}}<.lazy_load src={~p"/dashboard/sales"} reserve="12rem" aria-label="Loading sales report" /><div data-slot="lazy-load" data-trigger="load" role="status" aria-busy="true" aria-label="Loading sales report" hx-get="/lazy-load/sales?delay=900" hx-trigger="load" hx-swap="innerHTML" style="min-height:12rem" class="flex w-full items-center justify-center text-sm text-muted-foreground rounded-lg border p-4">
<span class="flex items-center gap-2">
<span class="size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true">
</span>
Loading…
</span>
</div>Further reading
Skeleton placeholder
Pass a composed <Skeleton> silhouette as the placeholder instead of the default spinner. Match the real content's shape so the swap is visually quiet and the reserved space is exact.
Children override the default spinner. Build the placeholder out of <Skeleton> bars that approximate the final layout — the closer the match, the less the page reflows when the real card arrives. Here the reserved height comes from the skeleton itself, so no explicit reserve is needed.
<LazyLoad src="/api/profile" ariaLabel="Loading profile card">
<Card>
<CardHeader>
<div class="flex items-center gap-3">
<Skeleton class="size-10 rounded-full" ariaLabel="Loading avatar" />
<div class="grid gap-2">
<Skeleton class="h-4 w-40" ariaLabel="Loading name" />
<Skeleton class="h-3 w-24" ariaLabel="Loading handle" />
</div>
</div>
</CardHeader>
<CardContent class="space-y-2">
<Skeleton class="h-3 w-full" ariaLabel="Loading bio line 1" />
<Skeleton class="h-3 w-5/6" ariaLabel="Loading bio line 2" />
</CardContent>
</Card>
</LazyLoad>{% call lazy_load(src="/api/profile", aria_label="Loading profile card") %}
{# skeleton silhouette matching the real card #}
{% endcall %}{{template "lazy_load" (dict "Src" "/api/profile" "AriaLabel" "Loading profile card" "Body" (htmlSafe "<!-- skeleton silhouette -->"))}}<.lazy_load src={~p"/api/profile"} aria-label="Loading profile card">
<%!-- skeleton silhouette matching the real card --%>
</.lazy_load><div data-slot="lazy-load" data-trigger="load" role="status" aria-busy="true" aria-label="Loading profile card" hx-get="/lazy-load/profile?delay=1100" hx-trigger="load" hx-swap="innerHTML" class="flex w-full items-center justify-center text-sm text-muted-foreground block w-full max-w-md">
<div data-slot="card" class="flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm w-full">
<div data-slot="card-header" class="@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6">
<div class="flex items-center gap-3">
<div role="status" aria-busy="true" aria-label="Loading avatar" data-slot="skeleton" class="animate-pulse rounded-md bg-muted size-10 rounded-full">
</div>
<div class="grid gap-2">
<div role="status" aria-busy="true" aria-label="Loading name" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-4 w-40">
</div>
<div role="status" aria-busy="true" aria-label="Loading handle" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-3 w-24">
</div>
</div>
</div>
</div>
<div data-slot="card-content" class="px-6 space-y-2">
<div role="status" aria-busy="true" aria-label="Loading bio line 1" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-3 w-full">
</div>
<div role="status" aria-busy="true" aria-label="Loading bio line 2" data-slot="skeleton" class="animate-pulse rounded-md bg-muted h-3 w-5/6">
</div>
</div>
</div>
</div>Further reading
Defer until scrolled into view
Set trigger="revealed" to hold the fetch until the container scrolls into the viewport — useful for below-the-fold panels. Use trigger="intersect" instead when the container lives inside an overflow-y:scroll box.
Same deferred swap, later trigger: hx-trigger="revealed" is backed by the browser's IntersectionObserver. Scroll the panel below into view to pull its contents in. The reserved height keeps the scroll position stable as the content lands.
<LazyLoad
src="/comments"
trigger="revealed"
reserve="10rem"
ariaLabel="Loading comments"
/>
{/* Inside an overflow-y:scroll container, use trigger="intersect". */}{{ lazy_load(src="/comments", trigger="revealed", reserve="10rem", aria_label="Loading comments") }}{{template "lazy_load" (dict "Src" "/comments" "Trigger" "revealed" "Reserve" "10rem" "AriaLabel" "Loading comments")}}<.lazy_load src={~p"/comments"} trigger="revealed" reserve="10rem" aria-label="Loading comments" /><div class="w-full max-w-md space-y-4">
<div class="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">Scroll down a touch — the panel below fetches when it enters the viewport.</div>
<div data-slot="lazy-load" data-trigger="revealed" role="status" aria-busy="true" aria-label="Loading comments" hx-get="/lazy-load/comments?delay=700" hx-trigger="revealed" hx-swap="innerHTML" style="min-height:10rem" class="flex w-full items-center justify-center text-sm text-muted-foreground rounded-lg border p-4">
<span class="flex items-center gap-2">
<span class="size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true">
</span>
Loading…
</span>
</div>
</div>Further reading
API Reference
<LazyLoad>
| Prop | Type | Default | Description |
|---|---|---|---|
src | string | — | URL this container fetches its own contents from. Sets hx-get. With the default innerHTML swap the response must NOT repeat hx-trigger="load", or the request loops.htmxhx-get |
trigger | "load"|"revealed"|"intersect" | "load" | When the fetch fires. "load" fires immediately when the element enters the DOM (deferred but eager); "revealed" fires when it scrolls into the page viewport; "intersect" fires once inside an overflow-y:scroll container (intersect once).htmxhx-trigger (load / revealed / intersect) |
swap | "innerHTML"|"outerHTML" | "innerHTML" | How the response lands. "innerHTML" replaces the contents and keeps this reserved-space wrapper; "outerHTML" replaces the wrapper wholesale (use when the response brings its own box).htmxhx-swap |
reserve | string | — | Reserved minimum height (any CSS length, e.g. "12rem" or "200px"). Sets min-height inline so the box holds its size before content arrives — prevents Cumulative Layout Shift (CLS).MDNmin-height |
ariaLabel | string | "Loading" | Accessible name for the loading region (e.g. "Loading sales report"). The wrapper is a role="status" + aria-busy="true" live region while content is in flight.MDNaria-busy |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |