shshadcn-htmx

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

2. Use it

components/ui/lazy-load.tsx
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
components/ui/lazy-load.tsx
/** @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

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

components/lazy-load.tmpl
{{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
components/lazy-load.tmpl
{{/* 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

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

snippets/lazy-load.html
<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
snippets/lazy-load.html
<!--
  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.

Loading…
<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>

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>

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.

Scroll down a touch — the panel below fetches when it enters the viewport.
Loading…
<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>

API Reference

<LazyLoad>

PropTypeDefaultDescription
srcstring
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
reservestring
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
ariaLabelstring"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
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference