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)を差し替えながら継ぎ足します。
次に読むオススメレシピ
参考リンク
このページの著者
経験:Webアプリ/業務システム
得意:PHP・JavaScript・MySQL・CSS
個人実績:フォーム生成基盤/クイズ学習プラットフォーム 等
詳しいプロフィールはこちら! もちもちみかんのプロフィール