shshadcn-htmx

Components

Feed

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.

Installation

1. Install via the shadcn CLI

npx shadcn@latest add http://localhost/r/feed.json

2. Use it

components/ui/feed.tsx
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>
Or copy the source manually
components/ui/feed.tsx
/** @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

templates/components/feed.html
{% 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() }}
View source
templates/components/feed.html
{# 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

templates/components/feed.tmpl
{{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")}}`))}}
View source
templates/components/feed.tmpl
{{/* 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

lib/my_app_web/components/feed.ex
<.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>
View source
lib/my_app_web/components/feed.ex
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

snippets/feed.html
<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>
View source
snippets/feed.html
<!--
  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>

Examples

Basic — a feed of articles

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.

Latest posts

Shipping hypermedia at scale

How we moved a dashboard from a SPA to server-rendered htmx — and why the page got faster.

Tailwind v4: the Oxide engine

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>

htmx — infinite scroll

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.

Stream

A field guide to hx-trigger

Every synthetic event htmx ships and when to reach for each.

Out-of-band swaps in practice

Update three regions from one response without a SPA framework.

Server-rendered SPAs

The hypermedia approach to rich apps — fewer moving parts, faster TTFB.

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

Keyboard navigation

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.

Try the keys

First article

Focus me, then press Page Down.

Second article

Page Up goes back, Ctrl+Home jumps here.

Third article

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>

API Reference

<Feed>

PropTypeDefaultDescription
tabindex0 | -10
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
busybooleanfalse
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
ariaLabelstring
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
ariaLabelledbystring
Id of a visible element (usually the heading) that names the feed. Preferred over ariaLabel when a title is on screen.MDNaria-labelledby
busybooleanfalse
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
classstring
Extra Tailwind classes appended to the root element.
hx-*any
Any htmx attribute. Forwarded onto the underlying element.htmxAttribute reference