shshadcn-htmx

Components

Load More

A self-replacing pagination trigger. Click a button or scroll a sentinel into view; htmx appends the next page and swaps in a fresh trigger via hx-swap="outerHTML". When the server omits the trigger, the chain ends. The click mode is a real <button> so it works without JavaScript.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/load-more.json

2. Use it

components/ui/load-more.tsx
import { LoadMore } from "@/components/ui/load-more"

{/* Click trigger — appends the next page, then replaces itself. */}
<LoadMore href="/comments?page=2" label="Show more comments" />

{/* Scroll sentinel — fires when it enters the viewport. */}
<LoadMore href="/contacts?page=2" trigger="intersect" />
Or copy the source manually
components/ui/load-more.tsx
/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"

// Load More — shadcn-htmx, htmx v4 + Tailwind v4.
//
// A self-replacing pagination trigger. It appends the next page and swaps a
// fresh trigger in its place; when the server omits the trigger, the chain
// ends. Two modes:
//   - "click"    → a real <button>. Works with no JS (it's a plain button);
//                  htmx upgrades the click into a request.
//   - "intersect"/"revealed" → a scroll sentinel that fires when it enters
//                  the viewport (IntersectionObserver under the hood).
//
// shadcn/ui has no "load more" widget (it's 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 loading patterns and the platform docs:
//   repos/htmx/www/src/content/patterns/01-loading/01-click-to-load.md
//     (button + hx-swap="outerHTML" + hx-target="this" → self-replace)
//   repos/htmx/www/src/content/patterns/01-loading/02-infinite-scroll.md
//     (sentinel + hx-trigger="revealed" / "intersect once")
//   repos/htmx/www/src/content/reference/01-attributes/06-hx-trigger.md
//     (verified v4: synthetic `revealed` and `intersect` events; `intersect`
//      supports root:<sel> and threshold:<float>; use `intersect once` inside
//      an overflow-y:scroll container, `revealed` for the page viewport)
//   repos/htmx/www/src/content/reference/01-attributes/07-hx-swap.md
//     (verified v4: `outerHTML` replaces the target element wholesale)
//   repos/htmx/www/src/content/reference/01-attributes/19-hx-indicator.md
//     (the htmx-request class rides on this element while in flight; the
//      .htmx-indicator child is revealed → our skeleton/spinner fallback)
//   repos/mdn/files/en-us/web/api/intersection_observer_api/index.md
//     (the platform API htmx's intersect/revealed triggers are built on —
//      "implementing infinite-scrolling websites … as you scroll")
//
// Why a real <button> for the click mode: progressive enhancement. Without JS
// the button still submits (wrap it in a <form action> if you need a true
// no-JS navigation); with htmx it self-replaces in place. No emulation of any
// platform feature — the trigger IS the platform's button / IntersectionObserver.
//
// Zero JS of our own: htmx owns the request lifecycle and the IntersectionObserver.
// The in-flight skeleton is pure CSS via the .htmx-indicator opacity contract.

export type LoadMoreTrigger = "click" | "intersect" | "revealed"

// Click mode renders a button styled like the ghost Button (so it reads as a
// quiet, full-width "show more" affordance); the sentinel modes render a
// muted, centred status strip like the feed sentinel.
const buttonClasses =
  "inline-flex w-full shrink-0 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium whitespace-nowrap outline-none transition-all " +
  "text-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 " +
  "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 " +
  "disabled:pointer-events-none disabled:opacity-50 " +
  // While the request this button triggers is in flight, htmx adds
  // .htmx-request here; we mute the trigger so it can't be re-fired.
  "[&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70"

const sentinelClasses =
  "flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground"

// The default inline spinner. It carries the htmx-indicator class so it is
// hidden until htmx flips on .htmx-request on the trigger, then fades in.
function Spinner() {
  return (
    <span
      class="htmx-indicator size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground"
      aria-hidden="true"
    />
  )
}

type LoadMoreProps = PropsWithChildren<{
  // Next-page URL. Sets hx-get for you.
  href?: string
  // "click" → <button>; "intersect"/"revealed" → scroll sentinel <div>.
  trigger?: LoadMoreTrigger
  // Visible label for the click button (ignored by sentinel modes, which use
  // their children / default spinner).
  label?: string
  // Accessible name. On the sentinel modes the visible text is decorative, so
  // an explicit label keeps AT announcements meaningful.
  ariaLabel?: string
  // Disable the click trigger (no effect on sentinel modes).
  disabled?: boolean
  class?: ClassValue
  id?: string
  // htmx / data / aria attributes ride onto the trigger element. Forwarded so
  // call sites can override hx-target, add hx-indicator, hx-vals, etc.
  [key: `hx-${string}`]: any
  [key: `data-${string}`]: any
  [key: `aria-${string}`]: any
}>

export function LoadMore(props: LoadMoreProps) {
  const {
    href,
    trigger = "click",
    label = "Load more",
    ariaLabel,
    disabled,
    class: className,
    id,
    children,
    ...rest
  } = props as any

  // Sentinel modes: revealed (page viewport) or intersect (overflow container).
  if (trigger === "intersect" || trigger === "revealed") {
    const hxTrigger = trigger === "intersect" ? "intersect once" : "revealed"
    return (
      <div
        id={id}
        data-slot="load-more"
        data-trigger={trigger}
        role="status"
        aria-label={ariaLabel ?? "Loading more"}
        hx-get={href}
        hx-trigger={hxTrigger}
        hx-swap="outerHTML"
        class={cn(sentinelClasses, className)}
        {...rest}
      >
        {children ?? (
          <>
            <Spinner />
            Loading more…
          </>
        )}
      </div>
    )
  }

  // Click mode: a real button that self-replaces. outerHTML + target=this so
  // the response (next items + a fresh trigger) takes this element's place.
  return (
    <button
      type="button"
      id={id}
      data-slot="load-more"
      data-trigger="click"
      disabled={disabled}
      aria-label={ariaLabel}
      hx-get={href}
      hx-trigger="click"
      hx-target="this"
      hx-swap="outerHTML"
      class={cn(buttonClasses, className)}
      {...rest}
    >
      <Spinner />
      {children ?? label}
    </button>
  )
}

1. Save the file

Copy load-more.html into templates/components/.

2. Use it

templates/components/load-more.html
{% from "components/load-more.html" import load_more %}

{{ load_more(href="/comments?page=2", label="Show more comments") }}

{{ load_more(href="/contacts?page=2", trigger="intersect") }}
View source
templates/components/load-more.html
{# Load More macro — shadcn-htmx, htmx v4 + Tailwind v4.
   Mirrors registry/ui/load-more.tsx.

   A self-replacing pagination trigger. It appends the next page and swaps a
   fresh trigger into its place (hx-swap="outerHTML"); when the server omits
   the trigger, the chain ends. trigger="click" renders a real <button>
   (works with no JS); trigger="intersect"/"revealed" renders a scroll
   sentinel (htmx infinite scroll, IntersectionObserver under the hood).

   Sources cited in load-more.tsx:
     repos/htmx/.../patterns/01-loading/01-click-to-load.md
     repos/htmx/.../patterns/01-loading/02-infinite-scroll.md
     repos/htmx/.../reference/01-attributes/{06-hx-trigger,07-hx-swap,19-hx-indicator}.md
     repos/mdn/.../web/api/intersection_observer_api/index.md

   Usage:
     {% from "components/load-more.html" import load_more %}

     {{ load_more(href="/comments?page=2", label="Show more comments") }}
     {{ load_more(href="/contacts?page=2", trigger="intersect") }} #}

{% macro load_more(href=none, trigger="click", label="Load more", aria_label=none, disabled=false, id=none, extra_class="", **attrs) %}
{% if trigger in ("intersect", "revealed") %}
<div
  {%- if id %} id="{{ id }}"{% endif %}
  data-slot="load-more" data-trigger="{{ trigger }}"
  role="status" aria-label="{{ aria_label or 'Loading more' }}"
  {% if href %}hx-get="{{ href }}"{% endif %}
  hx-trigger="{{ 'intersect once' if trigger == 'intersect' else 'revealed' }}" hx-swap="outerHTML"
  class="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground {{ extra_class }}"
  {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{% if caller %}{{ caller() }}{% else %}<span class="htmx-indicator size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true"></span>Loading more…{% endif %}</div>
{% else %}
<button type="button"
  {%- if id %} id="{{ id }}"{% endif %}
  data-slot="load-more" data-trigger="click"
  {% if disabled %}disabled{% endif %}
  {% if aria_label %}aria-label="{{ aria_label }}"{% endif %}
  {% if href %}hx-get="{{ href }}"{% endif %}
  hx-trigger="click" hx-target="this" hx-swap="outerHTML"
  class="inline-flex w-full shrink-0 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium whitespace-nowrap outline-none transition-all text-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 {{ extra_class }}"
  {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
><span class="htmx-indicator size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true"></span>{% if caller %}{{ caller() }}{% else %}{{ label }}{% endif %}</button>
{% endif %}
{% endmacro %}

1. Save the file

Add load-more.tmpl alongside your templates.

2. Use it

components/load-more.tmpl
{{template "load_more" (dict "Href" "/comments?page=2" "Label" "Show more comments")}}

{{template "load_more" (dict "Href" "/contacts?page=2" "Trigger" "intersect")}}
View source
components/load-more.tmpl
{{/* Load More template — shadcn-htmx, htmx v4 + Tailwind v4.
     Mirrors registry/ui/load-more.tsx.

     A self-replacing pagination trigger. It appends the next page and swaps a
     fresh trigger into its place (hx-swap="outerHTML"); when the server omits
     the trigger, the chain ends. Trigger "click" renders a real <button>
     (works with no JS); "intersect"/"revealed" renders a scroll sentinel
     (htmx infinite scroll, IntersectionObserver under the hood).

     Sources cited in load-more.tsx:
       repos/htmx/.../patterns/01-loading/01-click-to-load.md
       repos/htmx/.../patterns/01-loading/02-infinite-scroll.md
       repos/htmx/.../reference/01-attributes/{06-hx-trigger,07-hx-swap,19-hx-indicator}.md
       repos/mdn/.../web/api/intersection_observer_api/index.md

     Usage:
       {{template "load_more" (dict "Href" "/comments?page=2" "Label" "Show more comments")}}
       {{template "load_more" (dict "Href" "/contacts?page=2" "Trigger" "intersect")}} */}}

{{define "load_more"}}
{{- $trigger := or .Trigger "click" -}}
{{- $label := or .Label "Load more" -}}
{{- if or (eq $trigger "intersect") (eq $trigger "revealed") -}}
<div {{if .ID}}id="{{.ID}}"{{end}}
     data-slot="load-more" data-trigger="{{$trigger}}"
     role="status" aria-label="{{or .AriaLabel "Loading more"}}"
     {{if .Href}}hx-get="{{.Href}}"{{end}}
     hx-trigger="{{if eq $trigger "intersect"}}intersect once{{else}}revealed{{end}}" hx-swap="outerHTML"
     class="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground {{.Class}}">{{if .Body}}{{.Body}}{{else}}<span class="htmx-indicator size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true"></span>Loading more…{{end}}</div>
{{- else -}}
<button type="button" {{if .ID}}id="{{.ID}}"{{end}}
        data-slot="load-more" data-trigger="click"
        {{if .Disabled}}disabled{{end}}
        {{if .AriaLabel}}aria-label="{{.AriaLabel}}"{{end}}
        {{if .Href}}hx-get="{{.Href}}"{{end}}
        hx-trigger="click" hx-target="this" hx-swap="outerHTML"
        class="inline-flex w-full shrink-0 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium whitespace-nowrap outline-none transition-all text-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70 {{.Class}}"><span class="htmx-indicator size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true"></span>{{if .Body}}{{.Body}}{{else}}{{$label}}{{end}}</button>
{{- end -}}
{{end}}

1. Save the file

Drop load_more.ex into lib/my_app_web/components/.

2. Use it

lib/my_app_web/components/load_more.ex
<.load_more href={~p"/comments?page=2"} label="Show more comments" />

<.load_more href={~p"/contacts?page=2"} trigger="intersect" />
View source
lib/my_app_web/components/load_more.ex
defmodule ShadcnHtmx.Components.LoadMore do
  @moduledoc """
  Load More — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.

  A self-replacing pagination trigger. It appends the next page and swaps a
  fresh trigger into its place (`hx-swap="outerHTML"`); when the server omits
  the trigger from its response, the chain ends. Two modes:

    * `trigger="click"` — a real `<button>`. Works with no JS (it's a plain
      button); htmx upgrades the click into a request.
    * `trigger="intersect"` / `trigger="revealed"` — a scroll sentinel that
      fires when it enters the viewport (IntersectionObserver under the hood).
      Use `intersect` inside an `overflow-y: scroll` container, `revealed`
      for the page viewport.

  Sources (read, not copied) — see registry/ui/load-more.tsx:
    repos/htmx/.../patterns/01-loading/01-click-to-load.md
    repos/htmx/.../patterns/01-loading/02-infinite-scroll.md
    repos/htmx/.../reference/01-attributes/{06-hx-trigger,07-hx-swap,19-hx-indicator}.md
    repos/mdn/.../web/api/intersection_observer_api/index.md

  ## Examples

      <.load_more href={~p"/comments?page=2"} label="Show more comments" />

      <.load_more href={~p"/contacts?page=2"} trigger="intersect" />
  """

  use Phoenix.Component

  attr :href, :string, default: nil
  attr :trigger, :string, default: "click", values: ~w(click intersect revealed)
  attr :label, :string, default: "Load more"
  attr :"aria-label", :string, default: nil
  attr :disabled, :boolean, default: false
  attr :class, :string, default: nil
  attr :rest, :global
  slot :inner_block

  def load_more(assigns) do
    ~H"""
    <%= if @trigger in ["intersect", "revealed"] do %>
      <div
        data-slot="load-more"
        data-trigger={@trigger}
        role="status"
        aria-label={assigns[:"aria-label"] || "Loading more"}
        hx-get={@href}
        hx-trigger={if @trigger == "intersect", do: "intersect once", else: "revealed"}
        hx-swap="outerHTML"
        class={["flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground", @class]}
        {@rest}
      >
        <%= if @inner_block != [] do %>
          {render_slot(@inner_block)}
        <% else %>
          <span
            class="htmx-indicator size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground"
            aria-hidden="true"
          />
          Loading more…
        <% end %>
      </div>
    <% else %>
      <button
        type="button"
        data-slot="load-more"
        data-trigger="click"
        disabled={@disabled}
        aria-label={assigns[:"aria-label"]}
        hx-get={@href}
        hx-trigger="click"
        hx-target="this"
        hx-swap="outerHTML"
        class={[
          "inline-flex w-full shrink-0 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium whitespace-nowrap outline-none transition-all",
          "text-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
          "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
          "disabled:pointer-events-none disabled:opacity-50",
          "[&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70",
          @class
        ]}
        {@rest}
      >
        <span
          class="htmx-indicator size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground"
          aria-hidden="true"
        />
        <%= if @inner_block != [] do %>
          {render_slot(@inner_block)}
        <% else %>
          {@label}
        <% end %>
      </button>
    <% end %>
    """
  end
end

1. Save the file

Paste the markup; relies only on theme tokens.

2. Use it

snippets/load-more.html
<button type="button" data-slot="load-more" data-trigger="click"
        hx-get="/comments?page=2" hx-trigger="click" hx-target="this" hx-swap="outerHTML"
        class="inline-flex w-full items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium text-foreground hover:bg-accent …">
  <span class="htmx-indicator size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true"></span>
  Show more comments
</button>
View source
snippets/load-more.html
<!--
  shadcn-htmx — raw HTML load-more snippet.
  A self-replacing pagination trigger. It appends the next page and swaps a
  fresh trigger into its place (hx-swap="outerHTML" + hx-target="this"); when
  the server omits the trigger from its response, the chain ends.

  Two modes shown below:
    1. Click  — a real <button>. Works with no JS; htmx upgrades the click.
    2. Sentinel — fires on scroll (hx-trigger="revealed", or "intersect once"
       inside an overflow-y:scroll container). IntersectionObserver-backed.

  The inline spinner carries the htmx-indicator class, so it stays hidden
  until htmx adds .htmx-request to the trigger during the in-flight request,
  then fades in (the skeleton/loading fallback). Relies only on the theme
  tokens in styles.css — no JS of its own.

  Sources (read, not copied):
    repos/htmx/.../patterns/01-loading/01-click-to-load.md
    repos/htmx/.../patterns/01-loading/02-infinite-scroll.md
    repos/htmx/.../reference/01-attributes/{06-hx-trigger,07-hx-swap,19-hx-indicator}.md
    repos/mdn/.../web/api/intersection_observer_api/index.md
-->

<!-- 1. Click trigger. On click it GETs the next page and replaces itself with
        (next items + a fresh trigger). Omit the trigger on the last page. -->
<button type="button" data-slot="load-more" data-trigger="click"
        hx-get="/comments?page=2" hx-trigger="click" hx-target="this" hx-swap="outerHTML"
        class="inline-flex w-full shrink-0 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium whitespace-nowrap outline-none transition-all text-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&.htmx-request]:pointer-events-none [&.htmx-request]:opacity-70">
  <span class="htmx-indicator size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true"></span>
  Show more comments
</button>

<!-- 2. Scroll sentinel. When it scrolls into view, htmx GETs the next page
        and replaces it. Use "intersect once" inside an overflow-y:scroll box. -->
<div data-slot="load-more" data-trigger="revealed"
     role="status" aria-label="Loading more"
     hx-get="/contacts?page=2" hx-trigger="revealed" hx-swap="outerHTML"
     class="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
  <span class="htmx-indicator size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true"></span>
  Loading more…
</div>

Examples

Click to load

A real <button> with hx-target="this" + hx-swap="outerHTML". On click it requests the next page; the response (more items + a fresh button) replaces it. Omit the button on the last page and the chain stops.

This is the htmx click-to-load pattern. Because the trigger is a plain <button>, it works with no JavaScript at all — htmx only upgrades the click into a request and replaces the button in place. The inline spinner carries the htmx-indicator class, so it appears only while the request is in flight. Keep clicking until the button disappears.

1

1cg

daily reminder that the browser is the framework

S

S4RF

In 1997 I would have shipped this with a Perl script and a cronjob

uk

uncle k2

I am begging a front end dev to open the DOM inspector one (1) time.

<div id="comments" class="space-y-4">
  {/* first page rendered server-side */}
  <Comment author="1cg">…</Comment>
  {/* button loads page 2, then replaces itself */}
  <LoadMore href="/comments?page=2" label="Show more comments" />
</div>

// Server GET /comments?page=N returns the next batch +
// a fresh <LoadMore href="/comments?page=N+1" />. Omit it
// on the last page so the chain stops.
<div id="comments" class="space-y-4">
  {% for c in comments %}{{ comment(c) }}{% endfor %}
  {{ load_more(href="/comments?page=2", label="Show more comments") }}
</div>
<div id="comments" class="space-y-4">
  {{range .Comments}}{{template "comment" .}}{{end}}
  {{template "load_more" (dict "Href" "/comments?page=2" "Label" "Show more comments")}}
</div>
<div id="comments" class="space-y-4">
  <.comment :for={c <- @comments} comment={c} />
  <.load_more href={~p"/comments?page=2"} label="Show more comments" />
</div>
<div id="ex-click-host" class="w-full max-w-md space-y-4">
  <div class="flex gap-3" data-test="comment-1">
    <div class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">1</div>
    <div class="min-w-0 flex-1">
      <p class="text-sm font-semibold text-foreground">1cg</p>
      <p class="text-sm text-muted-foreground">daily reminder that the browser is the framework</p>
    </div>
  </div>
  <div class="flex gap-3" data-test="comment-2">
    <div class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">S</div>
    <div class="min-w-0 flex-1">
      <p class="text-sm font-semibold text-foreground">S4RF</p>
      <p class="text-sm text-muted-foreground">In 1997 I would have shipped this with a Perl script and a cronjob</p>
    </div>
  </div>
  <div class="flex gap-3" data-test="comment-3">
    <div class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">uk</div>
    <div class="min-w-0 flex-1">
      <p class="text-sm font-semibold text-foreground">uncle k2</p>
      <p class="text-sm text-muted-foreground">I am begging a front end dev to open the DOM inspector one (1) time.</p>
    </div>
  </div>
  <button type="button" data-slot="load-more" data-trigger="click" hx-get="/load-more/comments?page=2" hx-trigger="click" hx-target="this" hx-swap="outerHTML" class="inline-flex w-full shrink-0 items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium whitespace-nowrap outline-none transition-all text-foreground hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&amp;.htmx-request]:pointer-events-none [&amp;.htmx-request]:opacity-70" data-test="load-more-trigger">
    <span class="htmx-indicator size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true">
    </span>
    Show more comments
  </button>
</div>

Infinite scroll

Set trigger="intersect" (or "revealed") to swap the button for a scroll sentinel. It fires hx-trigger="intersect once" when it enters the viewport — no click needed — and self-replaces the same way. Use intersect inside an overflow container, revealed for the page viewport.

Same self-replacing chain, no button: the sentinel uses hx-trigger="intersect once", which is backed by the browser's IntersectionObserver. Scroll the box below to pull in the next page automatically.

1

1cg

daily reminder that the browser is the framework

S

S4RF

In 1997 I would have shipped this with a Perl script and a cronjob

uk

uncle k2

I am begging a front end dev to open the DOM inspector one (1) time.

Loading more…
{/* A scrollable overflow container needs keyboard access:
    tabindex + role="region" + an accessible name make it a
    focusable, named scrollable region (axe scrollable-region-focusable). */}
<div
  id="comments"
  tabindex={0}
  role="region"
  aria-label="Comments"
  class="max-h-64 space-y-4 overflow-y-auto"
>
  {/* first page rendered server-side */}
  <Comment author="1cg">…</Comment>
  {/* sentinel loads page 2 when scrolled into view */}
  <LoadMore href="/comments?page=2" trigger="intersect" />
</div>
{# Scrollable box → tabindex + role="region" + aria-label
   so keyboard users can focus and scroll it. #}
<div id="comments" tabindex="0" role="region" aria-label="Comments"
     class="max-h-64 space-y-4 overflow-y-auto">
  {% for c in comments %}{{ comment(c) }}{% endfor %}
  {{ load_more(href="/comments?page=2", trigger="intersect") }}
</div>
{{/* Scrollable box → tabindex + role="region" + aria-label
     so keyboard users can focus and scroll it. */}}
<div id="comments" tabindex="0" role="region" aria-label="Comments"
     class="max-h-64 space-y-4 overflow-y-auto">
  {{range .Comments}}{{template "comment" .}}{{end}}
  {{template "load_more" (dict "Href" "/comments?page=2" "Trigger" "intersect")}}
</div>
<%!-- Scrollable box → tabindex + role="region" + aria-label
     so keyboard users can focus and scroll it. --%>
<div id="comments" tabindex="0" role="region" aria-label="Comments"
     class="max-h-64 space-y-4 overflow-y-auto">
  <.comment :for={c <- @comments} comment={c} />
  <.load_more href={~p"/comments?page=2"} trigger="intersect" />
</div>
<div class="w-full max-w-md">
  <div id="ex-scroll-host" tabindex="0" role="region" aria-label="Comments" class="max-h-64 space-y-4 overflow-y-auto rounded-lg border p-4 focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
    <div class="flex gap-3" data-test="comment-1">
      <div class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">1</div>
      <div class="min-w-0 flex-1">
        <p class="text-sm font-semibold text-foreground">1cg</p>
        <p class="text-sm text-muted-foreground">daily reminder that the browser is the framework</p>
      </div>
    </div>
    <div class="flex gap-3" data-test="comment-2">
      <div class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">S</div>
      <div class="min-w-0 flex-1">
        <p class="text-sm font-semibold text-foreground">S4RF</p>
        <p class="text-sm text-muted-foreground">In 1997 I would have shipped this with a Perl script and a cronjob</p>
      </div>
    </div>
    <div class="flex gap-3" data-test="comment-3">
      <div class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">uk</div>
      <div class="min-w-0 flex-1">
        <p class="text-sm font-semibold text-foreground">uncle k2</p>
        <p class="text-sm text-muted-foreground">I am begging a front end dev to open the DOM inspector one (1) time.</p>
      </div>
    </div>
    <div data-slot="load-more" data-trigger="intersect" role="status" aria-label="Loading more" hx-get="/load-more/scroll?page=2" hx-trigger="intersect once" hx-swap="outerHTML" class="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground" data-test="load-more-trigger">
      <span class="htmx-indicator size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground" aria-hidden="true">
      </span>
      Loading more…
    </div>
  </div>
</div>

API Reference

<LoadMore>

PropTypeDefaultDescription
hrefstring
Next-page URL. Sets hx-get on the trigger.htmxhx-get
trigger"click"|"intersect"|"revealed""click"
How the load fires. "click" renders a real <button>; "intersect" / "revealed" render a scroll sentinel that fires when it enters the viewport. Use "intersect" inside an overflow-y:scroll container, "revealed" for the page viewport.htmxhx-trigger (intersect / revealed)
labelstring"Load more"
Visible text for the click button. Ignored by the sentinel modes, which use their children or the default spinner.
disabledbooleanfalse
Disable the click trigger — skipped from tab order, no request. No effect on the sentinel modes.
ariaLabelstring
Accessible name. On the sentinel modes the visible text is decorative, so set this to keep AT announcements meaningful (defaults to "Loading more").MDNaria-label
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference