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.json2. Use it
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
/** @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
{% 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
{# 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
{{template "load_more" (dict "Href" "/comments?page=2" "Label" "Show more comments")}}
{{template "load_more" (dict "Href" "/contacts?page=2" "Trigger" "intersect")}}View source
{{/* 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
<.load_more href={~p"/comments?page=2"} label="Show more comments" />
<.load_more href={~p"/contacts?page=2"} trigger="intersect" />View source
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
<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
<!--
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.
1cg
daily reminder that the browser is the framework
S4RF
In 1997 I would have shipped this with a Perl script and a cronjob
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 [&.htmx-request]:pointer-events-none [&.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>Further reading
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.
1cg
daily reminder that the browser is the framework
S4RF
In 1997 I would have shipped this with a Perl script and a cronjob
uncle k2
I am begging a front end dev to open the DOM inspector one (1) time.
{/* 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>Further reading
API Reference
<LoadMore>
| Prop | Type | Default | Description |
|---|---|---|---|
href | string | — | 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) |
label | string | "Load more" | Visible text for the click button. Ignored by the sentinel modes, which use their children or the default spinner. |
disabled | boolean | false | Disable the click trigger — skipped from tab order, no request. No effect on the sentinel modes. |
ariaLabel | string | — | 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 |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |