Shipping hypermedia at scale
How we moved a dashboard from a SPA to server-rendered htmx — and why the page got faster.
Components
A role="feed" container of <article> items that loads more as you scroll. A structure, not a widget — screen readers stay in reading mode while the page streams in content. The trailing sentinel uses htmx hx-trigger="revealed" for infinite scroll.
1. Install via the shadcn CLI
npx shadcn@latest add http://localhost/r/feed.json2. Use it
import { Feed, FeedArticle, FeedSentinel } from "@/components/ui/feed"
<Feed ariaLabelledby="feed-title">
<FeedArticle posinset={1} setsize={-1} labelledby="post-1-title" describedby="post-1-body" id="post-1">
<h3 id="post-1-title" class="font-semibold">Shipping hypermedia at scale</h3>
<p id="post-1-body" class="mt-1 text-sm text-muted-foreground">How we moved a dashboard to htmx.</p>
</FeedArticle>
<FeedSentinel href="/feed/page?page=2" />
</Feed>/** @jsxImportSource hono/jsx */
import type { PropsWithChildren } from "hono/jsx"
import { cn, type ClassValue } from "@/registry/lib/cn"
// Feed — shadcn-htmx, htmx v4 + Tailwind v4.
//
// shadcn/ui has no "feed" component (it's a structural ARIA pattern, not a
// widget), so there's no React source of truth to mirror. We build it
// straight from the WAI-ARIA APG Feed pattern:
// repos/aria-practices/content/patterns/feed/feed-pattern.html
// repos/aria-practices/content/patterns/feed/examples/feed-display.html
// repos/aria-practices/content/patterns/feed/examples/js/feed.js
// (the PageUp/PageDown + Ctrl+Home/End reference implementation our
// site.js keyboard contract is modelled on)
//
// Why this shape:
// - A feed is a STRUCTURE, not a widget. Screen readers stay in reading
// mode; the role="feed" container establishes an interoperability
// contract for reliably loading content as the user scrolls (APG
// "About This Pattern"). So the container is a plain <div role="feed">,
// NOT focusable.
// - Each unit of content is a real <article> (which already maps to
// role="article" per the HTML AAM — we set role="article" explicitly to
// stay faithful to the APG example markup and defensive against older AT).
// repos/mdn/files/en-us/web/html/reference/elements/article/index.md
// - Each article is focusable (tabindex="0") so AT reading cursors can land
// on it and the page can scroll it into view (APG: the article containing
// the reading cursor must contain DOM focus).
// - aria-posinset / aria-setsize position each article in the set; setsize
// can be -1 when the total is unknown (infinite feed). APG roles/states.
// - aria-labelledby names each article from its title; aria-describedby
// points at the primary content so AT users can skim.
// - aria-busy on the feed flips true while a batch is loading and false
// once the DOM is stable. APG: "extremely important that aria-busy is set
// to false when the operation is complete." With htmx the busy attribute
// rides on the freshly-swapped placeholder, so it's only present during
// the in-flight request.
//
// htmx infinite scroll (verified against v4):
// repos/htmx/www/src/content/patterns/01-loading/02-infinite-scroll.md
// repos/htmx/www/src/content/reference/01-attributes/06-hx-trigger.md#revealed
// The trailing sentinel uses hx-trigger="revealed" + hx-get + hx-swap=
// "outerHTML": when it scrolls into view it requests the next page, and the
// response (next articles + a fresh sentinel) replaces it — a self-extending
// chain. (Use "intersect once" instead when the feed lives inside an
// overflow-y:scroll container, per the htmx docs.)
//
// The component is layout-only: you hand it <FeedArticle> children and a
// <FeedSentinel>. The keyboard contract lives in public/site.js keyed on
// data-slot="feed".
type FeedProps = PropsWithChildren<{
// The feed needs an accessible name. Prefer ariaLabelledby pointing at a
// visible heading; fall back to ariaLabel when there's no visible title.
ariaLabel?: string
ariaLabelledby?: string
// True while a batch of articles is being added/removed. With htmx this is
// usually set on the sentinel placeholder, not here.
busy?: boolean
class?: ClassValue
id?: string
// htmx / data / aria attributes ride onto the feed container.
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
export function Feed(props: FeedProps) {
const { ariaLabel, ariaLabelledby, busy, class: className, id, children, ...rest } =
props as any
return (
<div
id={id}
role="feed"
data-slot="feed"
aria-label={ariaLabelledby ? undefined : ariaLabel}
aria-labelledby={ariaLabelledby}
aria-busy={busy ? "true" : undefined}
class={cn("flex flex-col gap-4", className)}
{...rest}
>
{children}
</div>
)
}
type FeedArticleProps = PropsWithChildren<{
// 1-based position in the feed.
posinset: number
// Total articles loaded (or total in the feed). Pass -1 when unknown.
setsize: number
// Id of the element inside this article that names it (the title). APG
// requires each article to be labelled by its distinguishing content.
labelledby: string
// Id(s) of the element(s) providing the primary content, so AT can skim.
describedby?: string
// 0 (default) or -1. MDN's feed role allows each article to be focusable
// "with tabindex of 0 or -1"; pass -1 to keep a long feed to a single Tab
// stop via a roving tabindex (the active article alone stays in the Tab
// sequence; Page Up/Down moves between articles).
// repos/mdn/files/en-us/web/accessibility/aria/reference/roles/feed_role/index.md
tabindex?: 0 | -1
class?: ClassValue
id?: string
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
[key: `aria-${string}`]: any
}>
export function FeedArticle(props: FeedArticleProps) {
const {
posinset,
setsize,
labelledby,
describedby,
tabindex = 0,
class: className,
id,
children,
...rest
} = props as any
return (
<article
id={id}
role="article"
data-slot="feed-article"
// Focusable so the AT reading cursor can rest on it and the page can
// scroll it into view (APG tabindex="0" on each article; MDN allows -1
// for a roving-tabindex feed).
tabindex={tabindex}
aria-posinset={posinset}
aria-setsize={setsize}
aria-labelledby={labelledby}
aria-describedby={describedby}
class={cn(
"rounded-xl border bg-card p-5 text-card-foreground shadow-sm",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
className,
)}
{...rest}
>
{children}
</article>
)
}
// Trailing placeholder that loads the next page when it scrolls into view.
// Defaults to the htmx infinite-scroll contract from the v4 docs: revealed +
// outerHTML so the response (next articles + a new sentinel) replaces it.
// Omit the sentinel from the server response when there are no more pages and
// the chain stops naturally.
type FeedSentinelProps = PropsWithChildren<{
// The next-page URL. Sets hx-get for you.
href?: string
// Default "revealed"; pass "intersect once" when the feed scrolls inside an
// overflow container (htmx docs note).
trigger?: string
// Marks the in-flight placeholder busy with aria-busy="true" while a batch
// loads — the documented "aria-busy rides on the sentinel" contract. The
// outerHTML swap clears it by replacing this element with the response, so
// busy never has to be reset to false manually (APG: aria-busy must be
// false once the operation completes).
// repos/aria-practices/content/patterns/feed/feed-pattern.html
busy?: boolean
class?: ClassValue
id?: string
[key: `hx-${string}`]: any
[key: `data-${string}`]: any
}>
export function FeedSentinel(props: FeedSentinelProps) {
const { href, trigger = "revealed", busy, class: className, id, children, ...rest } =
props as any
return (
<div
id={id}
data-slot="feed-sentinel"
hx-get={href}
hx-trigger={trigger}
hx-swap="outerHTML"
aria-busy={busy ? "true" : undefined}
class={cn(
"flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground",
className,
)}
{...rest}
>
{children ?? (
<>
<span
aria-hidden="true"
class="size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground"
/>
Loading more…
</>
)}
</div>
)
}
1. Save the file
Copy feed.html into templates/components/.
2. Use it
{% from "components/feed.html" import feed_open, feed_close, feed_article, feed_sentinel %}
{{ feed_open(aria_labelledby="feed-title") }}
{% call feed_article(posinset=1, setsize=-1, labelledby="post-1-title", describedby="post-1-body", id="post-1") %}
<h3 id="post-1-title" class="font-semibold">Shipping hypermedia at scale</h3>
<p id="post-1-body" class="mt-1 text-sm text-muted-foreground">How we moved a dashboard to htmx.</p>
{% endcall %}
{{ feed_sentinel(href="/feed/page?page=2") }}
{{ feed_close() }}{# Feed macros — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/feed.tsx.
A role="feed" container of role="article" items, per the WAI-ARIA APG
Feed pattern. The trailing sentinel uses hx-trigger="revealed" to load
the next page (htmx infinite scroll).
Usage:
{% from "components/feed.html" import feed_open, feed_close, feed_article,
feed_sentinel %}
{{ feed_open(aria_labelledby="feed-title") }}
{% call feed_article(posinset=1, setsize=-1, labelledby="post-1-title",
describedby="post-1-body", id="post-1") %}
<h3 id="post-1-title" class="font-semibold">Title</h3>
<p id="post-1-body" class="mt-1 text-sm text-muted-foreground">Body…</p>
{% endcall %}
{{ feed_sentinel(href="/feed/page?page=2") }}
{{ feed_close() }} #}
{% macro feed_open(aria_label=none, aria_labelledby=none, busy=false, id=none, extra_class="") %}
<div
{%- if id %} id="{{ id }}"{% endif %}
role="feed" data-slot="feed"
{% if aria_labelledby %}aria-labelledby="{{ aria_labelledby }}"{% elif aria_label %}aria-label="{{ aria_label }}"{% endif %}
{% if busy %}aria-busy="true"{% endif %}
class="flex flex-col gap-4 {{ extra_class }}">
{% endmacro %}
{% macro feed_close() %}
</div>
{% endmacro %}
{# tabindex: 0 (default) or -1 — MDN's feed role allows each article to be
focusable "with tabindex of 0 or -1"; pass -1 for a roving-tabindex feed. #}
{% macro feed_article(posinset, setsize, labelledby, describedby=none, tabindex=0, id=none, extra_class="", **attrs) %}
<article
{%- if id %} id="{{ id }}"{% endif %}
role="article" data-slot="feed-article" tabindex="{{ tabindex }}"
aria-posinset="{{ posinset }}" aria-setsize="{{ setsize }}"
aria-labelledby="{{ labelledby }}"
{% if describedby %}aria-describedby="{{ describedby }}"{% endif %}
class="rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none {{ extra_class }}"
{%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}="{{ v }}"{% endfor -%}
>{{ caller() }}</article>
{% endmacro %}
{# busy: aria-busy="true" on the in-flight placeholder while a batch loads;
the outerHTML swap clears it by replacing this element (APG: aria-busy must
be false once the operation completes). #}
{% macro feed_sentinel(href=none, trigger="revealed", busy=false, id=none, extra_class="") %}
<div
{%- if id %} id="{{ id }}"{% endif %}
data-slot="feed-sentinel"
{% if href %}hx-get="{{ href }}"{% endif %}
hx-trigger="{{ trigger }}" hx-swap="outerHTML"
{% if busy %}aria-busy="true"{% endif %}
class="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground {{ extra_class }}">
{% if caller %}{{ caller() }}{% else %}<span aria-hidden="true" class="size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground"></span>Loading more…{% endif %}
</div>
{% endmacro %}
1. Save the file
Add feed.tmpl alongside your other templates.
2. Use it
{{template "feed" (dict "AriaLabelledby" "feed-title" "Body" (htmlSafe `
{{template "feed_article" (dict "Posinset" 1 "Setsize" -1 "Labelledby" "post-1-title" "Describedby" "post-1-body" "ID" "post-1" "Body" (htmlSafe \`
<h3 id="post-1-title" class="font-semibold">Shipping hypermedia at scale</h3>
<p id="post-1-body" class="mt-1 text-sm text-muted-foreground">How we moved a dashboard to htmx.</p>\`))}}
{{template "feed_sentinel" (dict "Href" "/feed/page?page=2")}}`))}}{{/* Feed templates — shadcn-htmx, htmx v4 + Tailwind v4.
Mirrors registry/ui/feed.tsx.
role="feed" container of role="article" items (WAI-ARIA APG Feed pattern).
The trailing sentinel uses hx-trigger="revealed" to load the next page.
Usage:
{{template "feed" (dict "AriaLabelledby" "feed-title" "Body" (htmlSafe `
{{template "feed_article" (dict "Posinset" 1 "Setsize" -1
"Labelledby" "post-1-title" "Describedby" "post-1-body" "ID" "post-1"
"Body" (htmlSafe `<h3 id="post-1-title" class="font-semibold">Title</h3>
<p id="post-1-body" class="mt-1 text-sm text-muted-foreground">Body…</p>`))}}
{{template "feed_sentinel" (dict "Href" "/feed/page?page=2")}}`))}} */}}
{{define "feed"}}
<div {{if .ID}}id="{{.ID}}"{{end}}
role="feed" data-slot="feed"
{{if .AriaLabelledby}}aria-labelledby="{{.AriaLabelledby}}"{{else if .AriaLabel}}aria-label="{{.AriaLabel}}"{{end}}
{{if .Busy}}aria-busy="true"{{end}}
class="flex flex-col gap-4 {{.Class}}">{{.Body}}</div>
{{end}}
{{/* Tabindex: 0 (default) or -1 — MDN's feed role allows each article to be
focusable "with tabindex of 0 or -1"; pass -1 for a roving-tabindex feed. */}}
{{define "feed_article"}}
{{- $setsize := or .Setsize -1 -}}
{{- $tabindex := or .Tabindex 0 -}}
<article {{if .ID}}id="{{.ID}}"{{end}}
role="article" data-slot="feed-article" tabindex="{{$tabindex}}"
aria-posinset="{{.Posinset}}" aria-setsize="{{$setsize}}"
aria-labelledby="{{.Labelledby}}"
{{if .Describedby}}aria-describedby="{{.Describedby}}"{{end}}
class="rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none {{.Class}}">{{.Body}}</article>
{{end}}
{{/* Busy: aria-busy="true" on the in-flight placeholder while a batch loads;
the outerHTML swap clears it by replacing this element (APG: aria-busy must
be false once the operation completes). */}}
{{define "feed_sentinel"}}
{{- $trigger := or .Trigger "revealed" -}}
<div {{if .ID}}id="{{.ID}}"{{end}}
data-slot="feed-sentinel"
{{if .Href}}hx-get="{{.Href}}"{{end}}
hx-trigger="{{$trigger}}" hx-swap="outerHTML"
{{if .Busy}}aria-busy="true"{{end}}
class="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground {{.Class}}">{{if .Body}}{{.Body}}{{else}}<span aria-hidden="true" class="size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground"></span>Loading more…{{end}}</div>
{{end}}
1. Save the file
Drop feed.ex into lib/my_app_web/components/.
2. Use it
<.feed aria-labelledby="feed-title">
<.feed_article posinset={1} setsize={-1} labelledby="post-1-title" describedby="post-1-body" id="post-1">
<h3 id="post-1-title" class="font-semibold">Shipping hypermedia at scale</h3>
<p id="post-1-body" class="mt-1 text-sm text-muted-foreground">How we moved a dashboard to htmx.</p>
</.feed_article>
<.feed_sentinel href={~p"/feed/page?page=2"} />
</.feed>defmodule ShadcnHtmx.Components.Feed do
@moduledoc """
Feed — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.
A `role="feed"` container of `role="article"` items, following the
WAI-ARIA APG Feed pattern. A feed is a STRUCTURE, not a widget: screen
readers stay in reading mode while the page loads content as the user
scrolls. Each article is focusable (`tabindex="0"`) and carries
`aria-posinset` / `aria-setsize` (use `-1` for an unknown total),
`aria-labelledby` (its title) and `aria-describedby` (its primary
content, so AT users can skim).
The trailing sentinel uses htmx `hx-trigger="revealed"` + `hx-swap="outerHTML"`
to load the next page (infinite scroll) — the response (next articles plus a
fresh sentinel) replaces it, forming a self-extending chain.
## Examples
<.feed aria-labelledby="feed-title">
<.feed_article posinset={1} setsize={-1} labelledby="post-1-title"
describedby="post-1-body" id="post-1">
<h3 id="post-1-title" class="font-semibold">Title</h3>
<p id="post-1-body" class="mt-1 text-sm text-muted-foreground">Body…</p>
</.feed_article>
<.feed_sentinel href={~p"/feed/page?page=2"} />
</.feed>
"""
use Phoenix.Component
attr :"aria-label", :string, default: nil
attr :"aria-labelledby", :string, default: nil
attr :busy, :boolean, default: false
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def feed(assigns) do
~H"""
<div
role="feed"
data-slot="feed"
aria-label={!assigns[:"aria-labelledby"] && assigns[:"aria-label"]}
aria-labelledby={assigns[:"aria-labelledby"]}
aria-busy={@busy && "true"}
class={["flex flex-col gap-4", @class]}
{@rest}
>
{render_slot(@inner_block)}
</div>
"""
end
attr :posinset, :integer, required: true
attr :setsize, :integer, required: true
attr :labelledby, :string, required: true
attr :describedby, :string, default: nil
# 0 (default) or -1 — MDN's feed role allows each article to be focusable
# "with tabindex of 0 or -1"; pass -1 for a roving-tabindex feed.
attr :tabindex, :integer, default: 0
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block, required: true
def feed_article(assigns) do
~H"""
<article
role="article"
data-slot="feed-article"
tabindex={@tabindex}
aria-posinset={@posinset}
aria-setsize={@setsize}
aria-labelledby={@labelledby}
aria-describedby={@describedby}
class={[
"rounded-xl border bg-card p-5 text-card-foreground shadow-sm",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
@class
]}
{@rest}
>
{render_slot(@inner_block)}
</article>
"""
end
attr :href, :string, default: nil
attr :trigger, :string, default: "revealed"
# aria-busy="true" on the in-flight placeholder while a batch loads; the
# outerHTML swap clears it by replacing this element (APG: aria-busy must be
# false once the operation completes).
attr :busy, :boolean, default: false
attr :class, :string, default: nil
attr :rest, :global
slot :inner_block
def feed_sentinel(assigns) do
~H"""
<div
data-slot="feed-sentinel"
hx-get={@href}
hx-trigger={@trigger}
hx-swap="outerHTML"
aria-busy={@busy && "true"}
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
aria-hidden="true"
class="size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground"
/>
Loading more…
<% end %>
</div>
"""
end
end
1. Save the file
Paste the markup; it relies only on the theme tokens in styles.css.
2. Use it
<div role="feed" data-slot="feed" aria-labelledby="feed-title" class="flex flex-col gap-4">
<article role="article" data-slot="feed-article" tabindex="0"
aria-posinset="1" aria-setsize="-1"
aria-labelledby="post-1-title" aria-describedby="post-1-body" id="post-1"
class="rounded-xl border bg-card p-5 text-card-foreground shadow-sm …">
<h3 id="post-1-title" class="font-semibold">Shipping hypermedia at scale</h3>
<p id="post-1-body" class="mt-1 text-sm text-muted-foreground">How we moved a dashboard to htmx.</p>
</article>
<div data-slot="feed-sentinel" hx-get="/feed/page?page=2" hx-trigger="revealed" hx-swap="outerHTML"
class="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">Loading more…</div>
</div><!--
shadcn-htmx — raw HTML feed snippet.
role="feed" container of role="article" items (WAI-ARIA APG Feed pattern).
Each article is focusable and carries aria-posinset / aria-setsize
(-1 = unknown total), aria-labelledby (title) and aria-describedby (body).
The trailing sentinel uses htmx hx-trigger="revealed" to load the next
page; the response replaces it (hx-swap="outerHTML"), extending the chain.
Relies only on the theme tokens in styles.css. The PageUp/PageDown +
Ctrl+Home/End keyboard contract is wired by site.js (data-slot="feed").
-->
<h2 id="feed-title" class="text-lg font-semibold tracking-tight">Latest posts</h2>
<div role="feed" data-slot="feed" aria-labelledby="feed-title"
class="flex flex-col gap-4">
<article role="article" data-slot="feed-article" tabindex="0"
aria-posinset="1" aria-setsize="-1"
aria-labelledby="post-1-title" aria-describedby="post-1-body"
id="post-1"
class="rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
<h3 id="post-1-title" class="font-semibold">Shipping hypermedia at scale</h3>
<p id="post-1-body" class="mt-1 text-sm text-muted-foreground">
How we moved a dashboard from a SPA to server-rendered htmx — and why
the page got faster.
</p>
</article>
<article role="article" data-slot="feed-article" tabindex="0"
aria-posinset="2" aria-setsize="-1"
aria-labelledby="post-2-title" aria-describedby="post-2-body"
id="post-2"
class="rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
<h3 id="post-2-title" class="font-semibold">Tailwind v4: the Oxide engine</h3>
<p id="post-2-body" class="mt-1 text-sm text-muted-foreground">
A tour of CSS-first config, container queries, and the new color system.
</p>
</article>
<!-- Sentinel: when it scrolls into view, htmx GETs the next page and
replaces this node with (next articles + a fresh sentinel). Omit the
sentinel from the server response when there are no more pages. -->
<div data-slot="feed-sentinel" hx-get="/feed/page?page=2"
hx-trigger="revealed" hx-swap="outerHTML"
class="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
<span aria-hidden="true"
class="size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground"></span>
Loading more…
</div>
</div>
Each post is a real <article> with aria-posinset / aria-setsize, named by its title and described by its body so AT users can skim.
The container is a non-focusable role="feed" named by the heading via aria-labelledby. Each article is tabindex="0" so the screen-reader reading cursor can land on it. Use aria-setsize="-1" when the total is unknown.
How we moved a dashboard from a SPA to server-rendered htmx — and why the page got faster.
A tour of CSS-first config, container queries, and the new color system.
import { Feed, FeedArticle, FeedSentinel } from "@/components/ui/feed"
<Feed ariaLabelledby="feed-title">
<FeedArticle posinset={1} setsize={-1} labelledby="post-1-title" describedby="post-1-body" id="post-1">
<h3 id="post-1-title" class="font-semibold">Shipping hypermedia at scale</h3>
<p id="post-1-body" class="mt-1 text-sm text-muted-foreground">How we moved a dashboard to htmx.</p>
</FeedArticle>
<FeedSentinel href="/feed/page?page=2" />
</Feed>{% from "components/feed.html" import feed_open, feed_close, feed_article, feed_sentinel %}
{{ feed_open(aria_labelledby="feed-title") }}
{% call feed_article(posinset=1, setsize=-1, labelledby="post-1-title", describedby="post-1-body", id="post-1") %}
<h3 id="post-1-title" class="font-semibold">Shipping hypermedia at scale</h3>
<p id="post-1-body" class="mt-1 text-sm text-muted-foreground">How we moved a dashboard to htmx.</p>
{% endcall %}
{{ feed_sentinel(href="/feed/page?page=2") }}
{{ feed_close() }}{{template "feed" (dict "AriaLabelledby" "feed-title" "Body" (htmlSafe `
{{template "feed_article" (dict "Posinset" 1 "Setsize" -1 "Labelledby" "post-1-title" "Describedby" "post-1-body" "ID" "post-1" "Body" (htmlSafe \`
<h3 id="post-1-title" class="font-semibold">Shipping hypermedia at scale</h3>
<p id="post-1-body" class="mt-1 text-sm text-muted-foreground">How we moved a dashboard to htmx.</p>\`))}}
{{template "feed_sentinel" (dict "Href" "/feed/page?page=2")}}`))}}<.feed aria-labelledby="feed-title">
<.feed_article posinset={1} setsize={-1} labelledby="post-1-title" describedby="post-1-body" id="post-1">
<h3 id="post-1-title" class="font-semibold">Shipping hypermedia at scale</h3>
<p id="post-1-body" class="mt-1 text-sm text-muted-foreground">How we moved a dashboard to htmx.</p>
</.feed_article>
<.feed_sentinel href={~p"/feed/page?page=2"} />
</.feed><div class="w-full max-w-2xl">
<h2 id="feed-basic-title" class="mb-4 text-lg font-semibold tracking-tight">Latest posts</h2>
<div role="feed" data-slot="feed" aria-labelledby="feed-basic-title" class="flex flex-col gap-4">
<article id="basic-1" role="article" data-slot="feed-article" tabindex="0" aria-posinset="1" aria-setsize="2" aria-labelledby="basic-1-title" aria-describedby="basic-1-body" class="rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
<h3 id="basic-1-title" class="font-semibold">Shipping hypermedia at scale</h3>
<p id="basic-1-body" class="mt-1 text-sm text-muted-foreground">
How we moved a dashboard from a SPA to server-rendered htmx — and why the page got faster.
</p>
</article>
<article id="basic-2" role="article" data-slot="feed-article" tabindex="0" aria-posinset="2" aria-setsize="2" aria-labelledby="basic-2-title" aria-describedby="basic-2-body" class="rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none">
<h3 id="basic-2-title" class="font-semibold">Tailwind v4: the Oxide engine</h3>
<p id="basic-2-body" class="mt-1 text-sm text-muted-foreground">A tour of CSS-first config, container queries, and the new color system.</p>
</article>
</div>
</div>Further reading
The trailing sentinel has hx-get + hx-trigger="revealed". When it scrolls into view it requests the next page; the response (more articles + a fresh sentinel) replaces it with hx-swap="outerHTML".
This is the htmx v4 infinite-scroll pattern: the sentinel requests /feed/page?page=N on revealed, and outerHTML swaps in the next batch plus a new sentinel — a self-extending chain that stops when the server omits the sentinel. Scroll the box to load more.
Every synthetic event htmx ships and when to reach for each.
Update three regions from one response without a SPA framework.
The hypermedia approach to rich apps — fewer moving parts, faster TTFB.
<Feed ariaLabelledby="feed-title">
{/* first page rendered server-side */}
<FeedArticle posinset={1} setsize={-1} labelledby="p1" describedby="b1">…</FeedArticle>
<FeedArticle posinset={2} setsize={-1} labelledby="p2" describedby="b2">…</FeedArticle>
<FeedArticle posinset={3} setsize={-1} labelledby="p3" describedby="b3">…</FeedArticle>
{/* sentinel loads page 2 when revealed */}
<FeedSentinel href="/feed/page?page=2" />
</Feed>
// Server GET /feed/page?page=N returns the next FeedArticle batch +
// a fresh <FeedSentinel href="/feed/page?page=N+1" />. Omit the
// sentinel on the last page so the chain stops.{{ feed_open(aria_labelledby="feed-title") }}
{% for p in posts %}
{% call feed_article(posinset=p.pos, setsize=-1, labelledby=p.title_id, describedby=p.body_id) %}…{% endcall %}
{% endfor %}
{{ feed_sentinel(href="/feed/page?page=2") }}
{{ feed_close() }}{{template "feed" (dict "AriaLabelledby" "feed-title" "Body" (htmlSafe `
{{template "feed_article" (dict "Posinset" 1 "Setsize" -1 "Labelledby" "p1" "Describedby" "b1" "Body" (htmlSafe "…"))}}
{{template "feed_sentinel" (dict "Href" "/feed/page?page=2")}}`))}}<.feed aria-labelledby="feed-title">
<.feed_article :for={p <- @posts} posinset={p.pos} setsize={-1}
labelledby={p.title_id} describedby={p.body_id}>…</.feed_article>
<.feed_sentinel href={~p"/feed/page?page=2"} />
</.feed><div class="w-full max-w-2xl">
<h2 id="feed-inf-title" class="mb-4 text-lg font-semibold tracking-tight">Stream</h2>
<div class="max-h-80 overflow-y-auto rounded-lg border p-4">
<div id="feed-inf" role="feed" data-slot="feed" aria-labelledby="feed-inf-title" class="flex flex-col gap-4">
<article id="feed-post-1" role="article" data-slot="feed-article" tabindex="0" aria-posinset="1" aria-setsize="-1" aria-labelledby="feed-post-1-title" aria-describedby="feed-post-1-body" class="rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" data-test="feed-article-1">
<h3 id="feed-post-1-title" class="font-semibold">A field guide to hx-trigger</h3>
<p id="feed-post-1-body" class="mt-1 text-sm text-muted-foreground">Every synthetic event htmx ships and when to reach for each.</p>
</article>
<article id="feed-post-2" role="article" data-slot="feed-article" tabindex="0" aria-posinset="2" aria-setsize="-1" aria-labelledby="feed-post-2-title" aria-describedby="feed-post-2-body" class="rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" data-test="feed-article-2">
<h3 id="feed-post-2-title" class="font-semibold">Out-of-band swaps in practice</h3>
<p id="feed-post-2-body" class="mt-1 text-sm text-muted-foreground">Update three regions from one response without a SPA framework.</p>
</article>
<article id="feed-post-3" role="article" data-slot="feed-article" tabindex="0" aria-posinset="3" aria-setsize="-1" aria-labelledby="feed-post-3-title" aria-describedby="feed-post-3-body" class="rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" data-test="feed-article-3">
<h3 id="feed-post-3-title" class="font-semibold">Server-rendered SPAs</h3>
<p id="feed-post-3-body" class="mt-1 text-sm text-muted-foreground">The hypermedia approach to rich apps — fewer moving parts, faster TTFB.</p>
</article>
<div data-slot="feed-sentinel" hx-get="/feed/page?page=2" hx-trigger="revealed" hx-swap="outerHTML" class="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground" data-test="feed-sentinel">
<span aria-hidden="true" class="size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground">
</span>
Loading more…
</div>
</div>
</div>
</div>Inside the feed, Page Down / Page Up move focus between articles; Ctrl+Home jumps to the first article and Ctrl+End leaves the feed. These keys are APG-recommended but author-optional.
The feed role has no native desktop equivalent, so the APG only recommends these keys — they are author-optional. We wire them in site.js keyed on data-slot="feed". Tab onto an article below, then press Page Down / Page Up.
Focus me, then press Page Down.
Page Up goes back, Ctrl+Home jumps here.
Ctrl+End moves focus out of the feed.
// No extra markup — the keyboard contract is wired in site.js
// keyed on data-slot="feed" / data-slot="feed-article".
// Page Down → next article, Page Up → previous,
// Ctrl+Home → first article, Ctrl+End → out of the feed.{# Keyboard nav is provided by site.js (data-slot="feed"). #}{{/* Keyboard nav is provided by site.js (data-slot="feed"). */}}<%# Keyboard nav is provided by site.js (data-slot="feed"). %><div class="w-full max-w-2xl">
<h2 id="feed-kbd-title" class="mb-4 text-lg font-semibold tracking-tight">Try the keys</h2>
<div role="feed" data-slot="feed" aria-labelledby="feed-kbd-title" class="flex flex-col gap-4">
<article id="kbd-1" role="article" data-slot="feed-article" tabindex="0" aria-posinset="1" aria-setsize="3" aria-labelledby="kbd-1-title" aria-describedby="kbd-1-body" class="rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" data-test="kbd-article-1">
<h3 id="kbd-1-title" class="font-semibold">First article</h3>
<p id="kbd-1-body" class="mt-1 text-sm text-muted-foreground">Focus me, then press Page Down.</p>
</article>
<article id="kbd-2" role="article" data-slot="feed-article" tabindex="0" aria-posinset="2" aria-setsize="3" aria-labelledby="kbd-2-title" aria-describedby="kbd-2-body" class="rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" data-test="kbd-article-2">
<h3 id="kbd-2-title" class="font-semibold">Second article</h3>
<p id="kbd-2-body" class="mt-1 text-sm text-muted-foreground">Page Up goes back, Ctrl+Home jumps here.</p>
</article>
<article id="kbd-3" role="article" data-slot="feed-article" tabindex="0" aria-posinset="3" aria-setsize="3" aria-labelledby="kbd-3-title" aria-describedby="kbd-3-body" class="rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none" data-test="kbd-article-3">
<h3 id="kbd-3-title" class="font-semibold">Third article</h3>
<p id="kbd-3-body" class="mt-1 text-sm text-muted-foreground">Ctrl+End moves focus out of the feed.</p>
</article>
</div>
</div>Further reading
| Prop | Type | Default | Description |
|---|---|---|---|
tabindex | 0 | -1 | 0 | Article's tabindex. MDN's feed role allows each article to be focusable with 0 or -1; pass -1 to keep a long feed to a single Tab stop via a roving tabindex (only the active article stays in the Tab sequence, Page Up/Down moves between articles).MDNfeed role |
busy | boolean | false | Sets aria-busy="true" on the in-flight placeholder while a batch loads — the documented "aria-busy rides on the sentinel" contract. The outerHTML swap clears it by replacing this element with the response, so it never has to be reset to false manually.APGaria-busy |
ariaLabel | string | — | Accessible name for the feed when there's no visible heading. APG: the feed must be named via aria-label or aria-labelledby.APGFeed roles, states & properties |
ariaLabelledby | string | — | Id of a visible element (usually the heading) that names the feed. Preferred over ariaLabel when a title is on screen.MDNaria-labelledby |
busy | boolean | false | Sets aria-busy="true" while a batch of articles is being added or removed, then must return to false once the DOM is stable. With htmx this usually rides on the in-flight sentinel rather than the container.APGaria-busy |
class | string | — | Extra Tailwind classes appended to the root element. |
hx-* | any | — | Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference |