htmx 逆引きレシピ
無限スクロールするには?

公開日:
最終更新日:

無限スクロールは、一覧を読み進めるたびに「次へ」を押す手間を減らし、体験をスムーズにします。

htmxなら、末尾が見えたら次のHTMLを取りに行って、そこだけ差し替えるだけで実現できます。

このページでは、通知/コメントのフィード、ログの積み上げ、モバイル向け“もっと読む”の3パターンをまとめます。

使用するhtmx属性

  • hx-get:次ページ(追加分)のHTMLをGETで取得し、一覧に継ぎ足すために使います。
  • hx-trigger:末尾要素が見えた瞬間にリクエストを発火します(例:intersect once / load / click, revealed)。
  • hx-target:返ってきたHTMLを差し替える先の要素を指定します(例:結果エリア自身や、特定のコンテナ)。
  • hx-swap:差し替え方を指定します(無限スクロールは outerHTML で「末尾sentinelごと差し替え」が定番です)。

利用シーン

  • 「通知/コメント一覧」:フィード形式で、下まで読んだら自然に続きを読み込みたいときに便利です。
  • 「ログを下に積む」:監査ログ/システムログなど、時系列で“下へ下へ”追っていく一覧に向いています。
  • 「モバイルで“次へ”を減らす」:検索結果や記事一覧で、ボタン連打を減らしつつ“読めるだけ読む”体験にしたいときに使えます。

共通PHP

PHP(_inf_utils.php)

<php
// HTMLエスケープ関数を用意する
function h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }

// 整数を指定範囲に丸める関数を用意する
function clamp_int(int $v, int $min, int $max): int { return max($min, min($max, $v)); }

// ページングの開始/終了を計算する関数を用意する
function calc_range(int $page, int $per, int $total): array {

	// ページ番号の最低値を決める
	$pageMin = 1;

	// ページ番号の最大値を計算する
	$pageMax = max(1, (int)ceil($total / $per));

	// ページ番号を範囲内に丸める
	$page = clamp_int($page, $pageMin, $pageMax);

	// 開始インデックスを計算する(0始まり)
	$start = ($page - 1) * $per;

	// 終了インデックスを計算する(totalを超えない)
	$end = min($start + $per, $total);

	// 次ページがあるか判定する
	$hasMore = ($end < $total);

	// 計算結果を返す
	return [$page, $start, $end, $hasMore, $pageMax];
}

① 通知/コメント一覧(無限スクロール)

下端が見えた瞬間(revealed)に次のHTMLを取りに行き、一覧の末尾へ継ぎ足します。
「通知/コメント」の切り替えも、一覧だけ部分更新で完結します。

HTML

<div class="DEMO">

	<h4>① 通知/コメント一覧(無限スクロール)</h4>

	<form
		id="DEMO_INF_1_FORM"
		class="FORM"
		hx-get="/htmx/demo/_inf_feed.php"
		hx-trigger="load, change"
		hx-target="#DEMO_INF_1_AREA"
		hx-swap="innerHTML"
	>
		<label>
			種別
			<select name="kind">
				<option value="notif" selected>通知</option>
				<option value="comment">コメント</option>
			</select>
		</label>

		<input type="hidden" name="page" value="1">
		<input type="hidden" name="part" value="wrap">
	</form>

	<div id="DEMO_INF_1_AREA" class="MT1rm">
		<p class="HTMX-NOTE">読み込み中…</p>
	</div>

</div>

PHP

<?php
// 文字コード付きでHTMLを返す
header('Content-Type: text/html; charset=UTF-8');

// セッションを開始する(統一のため)
session_start();

// 共通関数を読み込む
require __DIR__ . '/_inf_utils.php';

// 種別を受け取る
$kind = (string)($_GET['kind'] ?? 'notif');

// ページ番号を受け取る
$page = (int)($_GET['page'] ?? 1);

// パーツ種別を受け取る(wrap=全体 / items=追加分)
$part = (string)($_GET['part'] ?? 'wrap');

// 1回で読む件数を決める
$per = 10;

// 総件数を種別ごとに決める(デモ用)
$total = ($kind === 'comment') ? 75 : 60;

// ページング範囲を計算する
[$page, $start, $end, $hasMore] = calc_range($page, $per, $total);

// 次ページ番号を計算する
$nextPage = $page + 1;

// 見出し表示用のラベルを決める
$label = ($kind === 'comment') ? 'コメント' : '通知';

// wrapのときは外枠から返す
if ($part === 'wrap') {

	// 上部の案内を出す
	echo '<p class="HTMX-NOTE">表示中:<strong>' . h($label) . '</strong>(下までスクロールすると続きを読み込みます)</p>';

	// ULを開始する
	echo '<ul id="DEMO_INF_1_LIST" class="INF_LIST">';

}

// 行を開始から終了まで描画する
for ($i = $start; $i < $end; $i++) {

	// 表示用の連番IDを作る
	$id = $i + 1;

	// ユーザー名を擬似的に決める
	$user = (['tanaka','sato','suzuki','yamada'][$id % 4]);

	// 時刻っぽい文字列を作る(デモ用)
	$time = sprintf('%02d:%02d', 9 + (($id * 3) % 10), ($id * 7) % 60);

	// タイトルを種別で変える
	$title = ($kind === 'comment')
		? 'コメントが追加されました'
		: '承認依頼が届きました';

	// 本文を擬似的に作る
	$body = ($kind === 'comment')
		? '「申請 #' . $id . '」に ' . $user . ' がコメントしました。'
		: '「申請 #' . $id . '」が承認待ちです。';

	// 1件分のliを出す
	echo '<li class="INF_ITEM">';

	// 1行目(見出し)を出す
	echo '<div><strong>' . h($title) . '</strong> <span class="INF_META">#' . h((string)$id) . '</span></div>';

	// 本文を出す
	echo '<div>' . h($body) . '</div>';

	// メタ情報を出す
	echo '<div class="INF_META">by ' . h($user) . ' ・ ' . h($time) . '</div>';

	// liを閉じる
	echo '</li>';

}

// 追加があるなら「読み込み中」行を出す
if ($hasMore) {

	// 追加取得URLを組み立てる
	$url = '/htmx/demo/_inf_feed.php?kind=' . rawurlencode($kind) . '&page=' . rawurlencode((string)$nextPage) . '&part=items';

	// sentinel(見えたら次を読み込む)を出す
	echo '<li id="DEMO_INF_1_MORE" class="INF_MORE HTMX-NOTE" hx-get="' . h($url) . '" hx-trigger="intersect once" hx-swap="outerHTML">読み込み中…</li>';


} else {

	// 末尾メッセージを出す
	echo '<li class="INF_MORE HTMX-NOTE">最後まで読み込みました。</li>';

}

// wrapのときはULを閉じる
if ($part === 'wrap') {

	// ULを閉じる
	echo '</ul>';

}

デモ

① 通知/コメント一覧(無限スクロール)

読み込み中…

解説

  • 初回は hx-trigger="load" で一覧の1ページ目(wrap)を取得し、結果エリアに描画します。
  • 一覧末尾に「読み込み中…」の要素(sentinel)を置き、hx-trigger="intersect once" で末尾が見えたら次ページ(items)を取得します。
  • 返ってくるHTMLは「次のアイテム群+次のsentinel」なので、hx-swap="outerHTML" で sentinel 自体を置き換えながら、実質的に一覧が下へ伸び続けます。
  • 種別(通知/コメント)の切り替えはフォームの change でwrapを取り直し、一覧をリセットしてから同じ仕組みで継ぎ足します。

② ログを下に積む(末尾へ追記)

監査ログやシステムログのように、スクロールしながら“下へ下へ”読み進める一覧に向きます。
末尾の「読み込み中」行が見えたら、次のログ行だけを取得して差し替えます。

HTML

<div class="DEMO">

	<h4>② ログを下に積む(末尾へ追記)</h4>

	<div
		id="DEMO_INF_2_AREA"
		hx-get="/htmx/demo/_inf_logs.php?page=1&part=wrap"
		hx-trigger="load"
		hx-target="this"
		hx-swap="innerHTML"
	>
		<p class="HTMX-NOTE">読み込み中…</p>
	</div>

</div>

PHP

<?php
// 文字コード付きでHTMLを返す
header('Content-Type: text/html; charset=UTF-8');

// セッションを開始する(統一のため)
session_start();

// 共通関数を読み込む
require __DIR__ . '/_inf_utils.php';

// ページ番号を受け取る
$page = (int)($_GET['page'] ?? 1);

// パーツ種別を受け取る(wrap=全体 / items=追加分)
$part = (string)($_GET['part'] ?? 'wrap');

// 1回で読む件数を決める
$per = 12;

// 総件数を決める(デモ用)
$total = 120;

// ページング範囲を計算する
[$page, $start, $end, $hasMore] = calc_range($page, $per, $total);

// 次ページ番号を計算する
$nextPage = $page + 1;

// wrapのときは外枠から返す
if ($part === 'wrap') {

	// 説明を出す
	echo '<p class="HTMX-NOTE">ログを“下に積む”例です(末尾が見えたら次の行を取得します)。</p>';

	// ULを開始する
	echo '<ul id="DEMO_INF_2_LIST" class="INF_LIST">';

}

// 行を開始から終了まで描画する
for ($i = $start; $i < $end; $i++) {

	// 表示用の行番号を作る
	$line = $i + 1;

	// レベルを擬似的に決める
	$lv = (['INFO','WARN','ERROR','DEBUG'][$line % 4]);

	// ユーザー名を擬似的に決める
	$user = (['system','tanaka','sato','suzuki'][$line % 4]);

	// 時刻っぽい文字列を作る(デモ用)
	$time = sprintf('%02d:%02d:%02d', 10 + (($line * 2) % 10), ($line * 5) % 60, ($line * 11) % 60);

	// メッセージを擬似的に作る
	$msg = '[' . $lv . '] job#' . $line . ' processed by ' . $user;

	// 1件分のliを出す
	echo '<li class="INF_ITEM">';

	// 1行でログっぽく出す
	echo '<div><strong>' . h($time) . '</strong> <span class="INF_META">' . h($lv) . '</span> ' . h($msg) . '</div>';

	// liを閉じる
	echo '</li>';

}

// 追加があるなら「読み込み中」行を出す
if ($hasMore) {

	// 追加取得URLを組み立てる
	$url = '/htmx/demo/_inf_logs.php?page=' . rawurlencode((string)$nextPage) . '&part=items';

	// sentinel(見えたら次を読み込む)を出す
	echo '<li id="DEMO_INF_2_MORE" class="INF_MORE HTMX-NOTE" hx-get="' . h($url) . '" hx-trigger="intersect once" hx-swap="outerHTML">読み込み中…</li>';

} else {

	// 末尾メッセージを出す
	echo '<li class="INF_MORE HTMX-NOTE">これ以上ログはありません。</li>';

}

// wrapのときはULを閉じる
if ($part === 'wrap') {

	// ULを閉じる
	echo '</ul>';

}

デモ

② ログを下に積む(末尾へ追記)

読み込み中…

解説

  • ログも基本は①と同じで、1ページ分のログ行を返し、末尾のsentinelで次のページを読み込みます。
  • ログは「下に積む」前提なので、ページごとのHTML生成をサーバ側で完結させ、クライアント側は差し替えだけにします。
  • 最後のページまで到達すると、sentinelの代わりに「これ以上ログはありません」などの末尾メッセージを返して終了します。

③ モバイルで“次へ”を減らす(Load More+自動)

検索結果や記事一覧で「次へ」を何度も押すのが面倒な場面に便利です。
基本は“もっと読む”ボタンですが、ボタンが見えたら自動でも読み込みます(click, revealed)。

HTML

<div class="DEMO">

	<h4>③ モバイルで“次へ”を減らす(Load More+自動)</h4>

	<p class="HTMX-NOTE">
	検索結果や記事一覧で「次へ」を何度も押すのが面倒な場面に便利です。<br>
	基本は“もっと読む”ボタンですが、ボタンが見えたら自動でも読み込みます(<code>click, revealed</code>)。
	</p>

	<div
		id="DEMO_INF_3_AREA"
		hx-get="/htmx/demo/_inf_mobile.php?page=1&part=wrap"
		hx-trigger="load"
		hx-target="this"
		hx-swap="innerHTML"
	>
		<p class="HTMX-NOTE">読み込み中…</p>
	</div>

</div>

PHP

<?php
// 文字コード付きでHTMLを返す
header('Content-Type: text/html; charset=UTF-8');

// セッションを開始する(統一のため)
session_start();

// 共通関数を読み込む
require __DIR__ . '/_inf_utils.php';

// ページ番号を受け取る
$page = (int)($_GET['page'] ?? 1);

// パーツ種別を受け取る(wrap=全体 / items=追加分)
$part = (string)($_GET['part'] ?? 'wrap');

// 1回で読む件数を決める
$per = 8;

// 総件数を決める(デモ用)
$total = 56;

// ページング範囲を計算する
[$page, $start, $end, $hasMore] = calc_range($page, $per, $total);

// 次ページ番号を計算する
$nextPage = $page + 1;

// wrapのときは外枠から返す
if ($part === 'wrap') {

	// 説明を出す
	echo '<p class="HTMX-NOTE">「もっと読む」ボタンを置きつつ、見えたら自動で読み込みます(<code>click, revealed</code>)。</p>';

	// ULを開始する
	echo '<ul id="DEMO_INF_3_LIST" class="INF_LIST">';

}

// 行を開始から終了まで描画する
for ($i = $start; $i < $end; $i++) {

	// 表示用のIDを作る
	$id = $i + 1;

	// カテゴリを擬似的に決める
	$cat = (['設計','UI','運用','DB'][$id % 4]);

	// タイトルを擬似的に作る
	$title = '記事タイトル #' . $id;

	// サマリを擬似的に作る
	$sum = 'モバイルで“次へ”を減らすためのUI例です(部分更新で継ぎ足し)。';

	// 1件分のliを出す
	echo '<li class="INF_ITEM">';

	// タイトル行を出す
	echo '<div><strong>' . h($title) . '</strong> <span class="INF_META">[' . h($cat) . ']</span></div>';

	// サマリを出す
	echo '<div>' . h($sum) . '</div>';

	// メタを出す
	echo '<div class="INF_META">id:' . h((string)$id) . '</div>';

	// liを閉じる
	echo '</li>';

}

// 追加があるなら「もっと読む」ボタン(+自動)を出す
if ($hasMore) {

	// 追加取得URLを組み立てる
	$url = '/htmx/demo/_inf_mobile.php?page=' . rawurlencode((string)$nextPage) . '&part=items';

	// sentinel(クリックでも自動でも動く)を出す
	echo '<li id="DEMO_INF_3_MORE" class="INF_MORE" hx-get="' . h($url) . '" hx-trigger="click, revealed" hx-swap="outerHTML">';

	// ボタンを出す
	echo '<button type="button" class="BTN is-ok">+ もっと読む</button>';

	// liを閉じる
	echo '</li>';

} else {

	// 末尾メッセージを出す
	echo '<li class="INF_MORE HTMX-NOTE">最後まで読み込みました。</li>';

}

// wrapのときはULを閉じる
if ($part === 'wrap') {

	// ULを閉じる
	echo '</ul>';

}

デモ

③ モバイルで“次へ”を減らす(Load More+自動)

検索結果や記事一覧で「次へ」を何度も押すのが面倒な場面に便利です。
基本は“もっと読む”ボタンですが、ボタンが見えたら自動でも読み込みます(click, revealed)。

読み込み中…

解説

  • 末尾に「+ もっと読む」ボタンを置き、hx-trigger="click, revealed" にして、押しても自動でも読み込めるようにします。
  • 自動読み込みは“見えたら動く”だけなので、通信量が不安な場合でもユーザー操作(クリック)に逃がせる構成です。
  • 返却HTMLは「次のアイテム群+次の“もっと読む”」なので、hx-swap="outerHTML" で末尾ボタン(sentinel)を差し替えながら継ぎ足します。

次に読むオススメレシピ

このページの著者

もちもちみかん(システムエンジニア)

社内SEとしてグループ企業向けの業務アプリを要件定義〜運用まで一気通貫で担当しています。

経験:Webアプリ/業務システム

得意:PHP・JavaScript・MySQL・CSS

個人実績:フォーム生成基盤クイズ学習プラットフォーム

詳しいプロフィールはこちら!  もちもちみかんのプロフィール

もちもちみかん0系くん
TOPへ

もちもちみかん.comとは


このサイトでは、コーディングがめんどうくさい人向けのお助けツールとして、フォームやCSSをノーコードで生成できる、
 もちもちみかん.forms
 もちもちみかん.css1
 もちもちみかん.css2
と言ったジェネレーターを用意してます。

また、このサイトを通じて、「もちもちみかん」のかわいさを普及したいとかんがえてます!