htmx 逆引きレシピ
「もっと読む」を実装するには?
公開日:
最終更新日:
「もっと読む」は、一覧を段階的に見せて初期描画を軽くするための定番UIです。
htmxなら、ボタンから次ページのHTML断片を取り、末尾へ追加するだけで自然に実装できます。
このページでは、最小構成・最終ページ処理・モバイル向けUIの3パターンをまとめます。
使用するhtmx属性
タグ:hx-get / hx-target / hx-swap
hx-get:次ページ(または次offset)のHTML断片を取得します。hx-target:取得した断片をどこへ反映するか指定します(一覧末尾やボタン領域)。hx-swap:beforeendで末尾追加、outerHTMLでボタン差し替えを行います。
利用シーン
- 一覧を段階表示:まずは数件だけ見せて、必要になったら「もっと読む」で追加したい
- 初期表示を軽く:初回の取得件数を絞り、体感速度と表示の安定感を上げたい
- モバイルで自然に追加:ページネーションより“押すだけ”で続きが読めるUIにしたい
共通PHP(共通)
3デモで共通利用するデータ・関数は別ファイルにまとめておきます。
以下は実ファイルを file_get_contents() で全文表示しています。
PHP(_load_more_data.php)
/htmx/demo/_load_more_data.php
<?php
// HTMLエスケープ関数を用意する
function load_more_h(string $s): string { return htmlspecialchars($s, ENT_QUOTES, 'UTF-8'); }
// デモ用の一覧データを用意する
$LOAD_MORE_ITEMS = [
['id' => 1, 'title' => '運用メモ: 監視項目を見直す', 'summary' => '夜間アラートの閾値を再調整して誤検知を減らす。', 'category' => '運用'],
['id' => 2, 'title' => 'UI改善: ボタン余白の統一', 'summary' => 'モバイルのタップ領域を44px以上に揃える。', 'category' => 'UI'],
['id' => 3, 'title' => '設計メモ: API分割方針', 'summary' => '一覧APIと詳細APIの責務を明確化する。', 'category' => '設計'],
['id' => 4, 'title' => 'DB運用: インデックス確認', 'summary' => '検索条件に合わせた複合インデックスを追加する。', 'category' => 'DB'],
['id' => 5, 'title' => '運用メモ: 障害対応手順', 'summary' => '一次切り分けチェックリストを更新する。', 'category' => '運用'],
['id' => 6, 'title' => 'UI改善: 一覧カードの整列', 'summary' => '長文タイトルでも高さが崩れないように調整。', 'category' => 'UI'],
['id' => 7, 'title' => '設計メモ: 権限モデル整理', 'summary' => 'ロール別の可視範囲を仕様として固定化。', 'category' => '設計'],
['id' => 8, 'title' => 'DB運用: バックアップ検証', 'summary' => '復元リハーサルを月次で自動実行する。', 'category' => 'DB'],
['id' => 9, 'title' => '運用メモ: 週次レポート簡素化', 'summary' => '重要KPIだけを先頭に表示して読みやすくする。', 'category' => '運用'],
['id' => 10, 'title' => 'UI改善: ラベル文言の統一', 'summary' => '同義語を減らして学習コストを下げる。', 'category' => 'UI'],
['id' => 11, 'title' => '設計メモ: 非同期キューの再検討', 'summary' => '即時処理と遅延処理の境界を再定義する。', 'category' => '設計'],
['id' => 12, 'title' => 'DB運用: 遅いSQLの棚卸し', 'summary' => '上位5件を継続監視して回帰を防ぐ。', 'category' => 'DB'],
['id' => 13, 'title' => '運用メモ: 標準手順の見直し', 'summary' => '手順書にスクリーンショットを追加する。', 'category' => '運用'],
['id' => 14, 'title' => 'UI改善: エラーメッセージ最適化', 'summary' => '原因と対処を1行で示し離脱を減らす。', 'category' => 'UI'],
['id' => 15, 'title' => '設計メモ: キャッシュ期限調整', 'summary' => '更新頻度に応じてTTLを3段階で運用する。', 'category' => '設計'],
['id' => 16, 'title' => 'DB運用: 接続数の上限監視', 'summary' => '急増時にアプリ側へ警告を通知する。', 'category' => 'DB'],
['id' => 17, 'title' => '運用メモ: ログ保持ポリシー', 'summary' => '高頻度ログは7日、監査ログは1年保持する。', 'category' => '運用'],
['id' => 18, 'title' => 'UI改善: 一覧余白の最適化', 'summary' => '密度を保ちつつ読みやすい行間へ調整。', 'category' => 'UI'],
['id' => 19, 'title' => '設計メモ: 外部連携の再試行', 'summary' => '指数バックオフを導入して失敗耐性を上げる。', 'category' => '設計'],
['id' => 20, 'title' => 'DB運用: ロック競合の抑制', 'summary' => '更新順序を見直して待機時間を短縮する。', 'category' => 'DB'],
['id' => 21, 'title' => '運用メモ: 連絡テンプレ整理', 'summary' => '一次報告テンプレを用途別に分割する。', 'category' => '運用'],
['id' => 22, 'title' => 'UI改善: 端末別フォント調整', 'summary' => '小画面でも可読性が落ちないサイズへ統一。', 'category' => 'UI'],
['id' => 23, 'title' => '設計メモ: 監査イベント定義', 'summary' => '記録対象イベントを仕様に明文化する。', 'category' => '設計'],
['id' => 24, 'title' => 'DB運用: バッチ分割実行', 'summary' => '重い更新を小分けにしてタイムアウトを回避。', 'category' => 'DB'],
];
// クエリパラメータを数値whitelistで取得する関数を用意する
function load_more_pick_int(array $source, string $key, int $default, int $min, int $max): int {
// 指定キーが未設定なら既定値を返す
if (!isset($source[$key])) return $default;
// 配列などの非スカラー値は拒否する
if (!is_scalar($source[$key])) return $default;
// 文字列にして前後空白を除去する
$raw = trim((string)$source[$key]);
// 半角数字のみ許可する
if ($raw === '' || preg_match('/\A[0-9]+\z/', $raw) !== 1) return $default;
// 整数へ変換する
$value = (int)$raw;
// 下限を適用する
if ($value < $min) $value = $min;
// 上限を適用する
if ($value > $max) $value = $max;
// 正規化済みの整数を返す
return $value;
}
// 一覧データ全体を返す関数を用意する
function load_more_get_items(): array {
// 共有データを参照する
global $LOAD_MORE_ITEMS;
// 配列を返す
return array_values($LOAD_MORE_ITEMS);
}
// page/limitで切り出す関数を用意する
function load_more_slice_by_page(int $page, int $limit): array {
// 全件データを取得する
$items = load_more_get_items();
// 総件数を計算する
$total = count($items);
// limitの最小値を補正する
if ($limit < 1) $limit = 1;
// pageの最小値を補正する
if ($page < 1) $page = 1;
// 最大ページ数を計算する
$pageMax = max(1, (int)ceil($total / $limit));
// pageの上限を補正する
if ($page > $pageMax) $page = $pageMax;
// 開始位置を計算する
$offset = ($page - 1) * $limit;
// 指定範囲を切り出す
$rows = array_slice($items, $offset, $limit);
// 次ページ番号を計算する
$nextPage = $page + 1;
// 続きがあるか判定する
$hasMore = ($offset + count($rows) < $total);
// 計算結果を返す
return [$rows, $offset, $nextPage, $hasMore, $total];
}
// offset/limitで切り出す関数を用意する
function load_more_slice_by_offset(int $offset, int $limit): array {
// 全件データを取得する
$items = load_more_get_items();
// 総件数を計算する
$total = count($items);
// limitの最小値を補正する
if ($limit < 1) $limit = 1;
// offsetの最小値を補正する
if ($offset < 0) $offset = 0;
// offsetの上限を補正する
if ($offset > $total) $offset = $total;
// 指定範囲を切り出す
$rows = array_slice($items, $offset, $limit);
// 次offsetを計算する
$nextOffset = $offset + count($rows);
// 続きがあるか判定する
$hasMore = ($nextOffset < $total);
// 計算結果を返す
return [$rows, $nextOffset, $hasMore, $total];
}
// 一覧行HTMLを作る関数を用意する
function load_more_render_rows(array $rows): string {
// 返却HTMLの初期値を用意する
$html = '';
// 行を順番に描画する
foreach ($rows as $row) {
// 表示用IDを取り出す
$id = (string)($row['id'] ?? '');
// タイトルを取り出す
$title = (string)($row['title'] ?? '');
// サマリを取り出す
$summary = (string)($row['summary'] ?? '');
// カテゴリを取り出す
$category = (string)($row['category'] ?? '');
// 1件分のLIを追加する
$html .= '<li class="INF_ITEM">';
// タイトル行を追加する
$html .= '<div><strong>' . load_more_h($title) . '</strong> <span class="INF_META">#' . load_more_h($id) . ' [' . load_more_h($category) . ']</span></div>';
// サマリ行を追加する
$html .= '<div>' . load_more_h($summary) . '</div>';
// LIを閉じる
$html .= '</li>';
}
// 組み立てたHTMLを返す
return $html;
}
① 最小構成(ボタンで次のN件を追加)
最初の数件は先に表示し、ボタン押下で次ページ分だけ取得して末尾へ追加します。
追加先は hx-target で一覧ULを指定し、hx-swap="beforeend" を使うのが基本です。
HTML
./_demo_html_1.php
<div class="DEMO">
<h4>① 最小構成(ボタンで次のN件を追加)</h4>
<ul id="DEMO_LOAD_1_LIST" class="INF_LIST">
<li class="INF_ITEM"><div><strong>運用メモ: 監視項目を見直す</strong> <span class="INF_META">#1 [運用]</span></div><div>夜間アラートの閾値を再調整して誤検知を減らす。</div></li>
<li class="INF_ITEM"><div><strong>UI改善: ボタン余白の統一</strong> <span class="INF_META">#2 [UI]</span></div><div>モバイルのタップ領域を44px以上に揃える。</div></li>
<li class="INF_ITEM"><div><strong>設計メモ: API分割方針</strong> <span class="INF_META">#3 [設計]</span></div><div>一覧APIと詳細APIの責務を明確化する。</div></li>
<li class="INF_ITEM"><div><strong>DB運用: インデックス確認</strong> <span class="INF_META">#4 [DB]</span></div><div>検索条件に合わせた複合インデックスを追加する。</div></li>
</ul>
<div id="DEMO_LOAD_1_MORE_WRAP" class="MT1rm">
<button
id="DEMO_LOAD_1_MORE_BTN"
type="button"
class="BTN"
hx-get="/htmx/demo/_load_more_1.php?page=2&limit=4"
hx-target="#DEMO_LOAD_1_LIST"
hx-swap="beforeend"
>+ もっと読む</button>
</div>
</div>PHP
/htmx/demo/_load_more_1.php
<?php
// 文字コード付きでHTMLを返す
header('Content-Type: text/html; charset=UTF-8');
// セッションを開始する(統一のため)
session_start();
// 共通データを読み込む
require __DIR__ . '/_load_more_data.php';
// pageを数値whitelistで取得する
$page = load_more_pick_int($_GET, 'page', 2, 1, 99);
// limitを数値whitelistで取得する
$limit = load_more_pick_int($_GET, 'limit', 4, 1, 8);
// page単位でデータを切り出す
[$rows, $offset, $nextPage, $hasMore, $total] = load_more_slice_by_page($page, $limit);
// 一覧行を返す
echo load_more_render_rows($rows);
// 続きがあるときは次ボタンへ差し替える
if ($hasMore) {
// 次URLを組み立てる
$url = '/htmx/demo/_load_more_1.php?page=' . rawurlencode((string)$nextPage) . '&limit=' . rawurlencode((string)$limit);
// ボタン枠をOOBで差し替える
echo '<div id="DEMO_LOAD_1_MORE_WRAP" class="MT1rm" hx-swap-oob="outerHTML">';
// 次ボタンを出す
echo '<button id="DEMO_LOAD_1_MORE_BTN" type="button" class="BTN" hx-get="' . load_more_h($url) . '" hx-target="#DEMO_LOAD_1_LIST" hx-swap="beforeend">+ もっと読む</button>';
// 件数メモを出す
echo '<p class="INF_META">表示中:' . load_more_h((string)($offset + count($rows))) . ' / ' . load_more_h((string)$total) . '</p>';
// ボタン枠を閉じる
echo '</div>';
} else {
// 完了表示へ差し替える
echo '<div id="DEMO_LOAD_1_MORE_WRAP" class="MT1rm" hx-swap-oob="outerHTML"><p class="HTMX-NOTE">すべて読み込み済みです。</p></div>';
}
デモ
① 最小構成(ボタンで次のN件を追加)
- 運用メモ: 監視項目を見直す #1 [運用]夜間アラートの閾値を再調整して誤検知を減らす。
- UI改善: ボタン余白の統一 #2 [UI]モバイルのタップ領域を44px以上に揃える。
- 設計メモ: API分割方針 #3 [設計]一覧APIと詳細APIの責務を明確化する。
- DB運用: インデックス確認 #4 [DB]検索条件に合わせた複合インデックスを追加する。
解説
- ボタン押下で次の4件を取得し、
beforeendで一覧の末尾へ追記します。 - サーバ側は
page/limitを数値whitelistで受け取り、範囲外値を補正します。 - 本文は
htmlspecialchars経由で出力し、XSSを避けます。
② 一覧を段階表示(最後のページでボタン差し替え)
レスポンス側で「次がある/ない」を判定し、ボタン行を outerHTML で差し替えます。
最後のページではボタンを消し、「すべて読み込み済み」に置き換えます。
HTML
./_demo_html_2.php
<div class="DEMO">
<h4>② 一覧を段階表示(最後のページでボタン差し替え)</h4>
<div
id="DEMO_LOAD_2_AREA"
hx-get="/htmx/demo/_load_more_2.php?page=1&limit=5&part=wrap"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML"
>
<p class="HTMX-NOTE">読み込み中...</p>
</div>
</div>PHP
/htmx/demo/_load_more_2.php
<?php
// 文字コード付きでHTMLを返す
header('Content-Type: text/html; charset=UTF-8');
// セッションを開始する(統一のため)
session_start();
// 共通データを読み込む
require __DIR__ . '/_load_more_data.php';
// pageを数値whitelistで取得する
$page = load_more_pick_int($_GET, 'page', 1, 1, 99);
// limitを数値whitelistで取得する
$limit = load_more_pick_int($_GET, 'limit', 5, 1, 10);
// partを受け取る
$part = (string)($_GET['part'] ?? 'wrap');
// partを許可値で補正する
if ($part !== 'wrap' && $part !== 'items') $part = 'wrap';
// page単位でデータを切り出す
[$rows, $offset, $nextPage, $hasMore, $total] = load_more_slice_by_page($page, $limit);
// wrapのときはULを開始する
if ($part === 'wrap') {
// 説明文を出す
echo '<p class="HTMX-NOTE">最後のページではボタンを「すべて読み込み済み」に置き換えます。</p>';
// ULを開始する
echo '<ul id="DEMO_LOAD_2_LIST" class="INF_LIST">';
}
// 一覧行を返す
echo load_more_render_rows($rows);
// 続きがあるときは次ボタンを返す
if ($hasMore) {
// 次URLを組み立てる
$url = '/htmx/demo/_load_more_2.php?page=' . rawurlencode((string)$nextPage) . '&limit=' . rawurlencode((string)$limit) . '&part=items';
// ボタン行を出す
echo '<li id="DEMO_LOAD_2_MORE" class="INF_MORE">';
// 次ボタンを出す
echo '<button type="button" class="BTN" hx-get="' . load_more_h($url) . '" hx-target="#DEMO_LOAD_2_MORE" hx-swap="outerHTML">+ もっと読む</button>';
// 件数メモを出す
echo '<div class="INF_META">表示中:' . load_more_h((string)($offset + count($rows))) . ' / ' . load_more_h((string)$total) . '</div>';
// ボタン行を閉じる
echo '</li>';
} else {
// 完了行を出す
echo '<li id="DEMO_LOAD_2_MORE" class="INF_MORE HTMX-NOTE">すべて読み込み済みです。</li>';
}
// wrapのときはULを閉じる
if ($part === 'wrap') {
// ULを閉じる
echo '</ul>';
}
デモ
② 一覧を段階表示(最後のページでボタン差し替え)
読み込み中...
解説
- ボタン自身をターゲットにして
outerHTML差し替えし、次ボタンまたは完了メッセージへ切り替えます。 - ページ終端判定はサーバ側で行うため、クライアント側の条件分岐を増やさずに済みます。
- 最後に「すべて読み込み済み」を返すことで、ユーザーに状態が明確に伝わります。
③ モバイルで自然に追加(固定ボタン+indicator)
モバイルでは、操作ボタンを下部に固定して押しやすくすると離脱を減らせます。
hx-indicator を付けて、追加中の状態も分かるようにします。
HTML
./_demo_html_3.php
<div class="DEMO">
<h4>③ モバイルで自然に追加(固定ボタン+indicator)</h4>
<ul id="DEMO_LOAD_3_LIST" class="INF_LIST">
<li class="INF_ITEM"><div><strong>運用メモ: 監視項目を見直す</strong> <span class="INF_META">#1 [運用]</span></div><div>夜間アラートの閾値を再調整して誤検知を減らす。</div></li>
<li class="INF_ITEM"><div><strong>UI改善: ボタン余白の統一</strong> <span class="INF_META">#2 [UI]</span></div><div>モバイルのタップ領域を44px以上に揃える。</div></li>
<li class="INF_ITEM"><div><strong>設計メモ: API分割方針</strong> <span class="INF_META">#3 [設計]</span></div><div>一覧APIと詳細APIの責務を明確化する。</div></li>
<li class="INF_ITEM"><div><strong>DB運用: インデックス確認</strong> <span class="INF_META">#4 [DB]</span></div><div>検索条件に合わせた複合インデックスを追加する。</div></li>
<li class="INF_ITEM"><div><strong>運用メモ: 障害対応手順</strong> <span class="INF_META">#5 [運用]</span></div><div>一次切り分けチェックリストを更新する。</div></li>
<li class="INF_ITEM"><div><strong>UI改善: 一覧カードの整列</strong> <span class="INF_META">#6 [UI]</span></div><div>長文タイトルでも高さが崩れないように調整。</div></li>
</ul>
<div id="DEMO_LOAD_3_STATE" class="INF_META MT1rm">表示件数:6 / 24</div>
<div id="DEMO_LOAD_3_ACTION" style="position:sticky;bottom:.5rem;background:rgba(255,255,255,.96);padding:.5rem;border:1px solid rgba(0,0,0,.12);">
<button
type="button"
class="BTN"
hx-get="/htmx/demo/_load_more_3.php?offset=6&limit=6"
hx-target="#DEMO_LOAD_3_LIST"
hx-swap="beforeend"
hx-indicator="#DEMO_LOAD_3_LOADING"
>+ もっと読む</button>
<span id="DEMO_LOAD_3_LOADING" class="LOADING">読み込み中...</span>
</div>
</div>PHP
/htmx/demo/_load_more_3.php
<?php
// 文字コード付きでHTMLを返す
header('Content-Type: text/html; charset=UTF-8');
// セッションを開始する(統一のため)
session_start();
// 共通データを読み込む
require __DIR__ . '/_load_more_data.php';
// offsetを数値whitelistで取得する
$offset = load_more_pick_int($_GET, 'offset', 6, 0, 200);
// limitを数値whitelistで取得する
$limit = load_more_pick_int($_GET, 'limit', 6, 1, 10);
// offset単位でデータを切り出す
[$rows, $nextOffset, $hasMore, $total] = load_more_slice_by_offset($offset, $limit);
// 一覧行を返す
echo load_more_render_rows($rows);
// 表示件数をOOBで更新する
echo '<div id="DEMO_LOAD_3_STATE" class="INF_META MT1rm" hx-swap-oob="outerHTML">表示件数:' . load_more_h((string)$nextOffset) . ' / ' . load_more_h((string)$total) . '</div>';
// 続きがあるときは次ボタンをOOBで更新する
if ($hasMore) {
// 次URLを組み立てる
$url = '/htmx/demo/_load_more_3.php?offset=' . rawurlencode((string)$nextOffset) . '&limit=' . rawurlencode((string)$limit);
// 操作バーを出す
echo '<div id="DEMO_LOAD_3_ACTION" style="position:sticky;bottom:.5rem;background:rgba(255,255,255,.96);padding:.5rem;border:1px solid rgba(0,0,0,.12);" hx-swap-oob="outerHTML">';
// 次ボタンを出す
echo '<button type="button" class="BTN" hx-get="' . load_more_h($url) . '" hx-target="#DEMO_LOAD_3_LIST" hx-swap="beforeend" hx-indicator="#DEMO_LOAD_3_LOADING">+ もっと読む</button>';
// ローディング表示を出す
echo '<span id="DEMO_LOAD_3_LOADING" class="LOADING">読み込み中...</span>';
// 操作バーを閉じる
echo '</div>';
} else {
// 完了表示へ差し替える
echo '<div id="DEMO_LOAD_3_ACTION" class="HTMX-NOTE" hx-swap-oob="outerHTML">すべて読み込み済みです。</div>';
}
デモ
③ モバイルで自然に追加(固定ボタン+indicator)
- 運用メモ: 監視項目を見直す #1 [運用]夜間アラートの閾値を再調整して誤検知を減らす。
- UI改善: ボタン余白の統一 #2 [UI]モバイルのタップ領域を44px以上に揃える。
- 設計メモ: API分割方針 #3 [設計]一覧APIと詳細APIの責務を明確化する。
- DB運用: インデックス確認 #4 [DB]検索条件に合わせた複合インデックスを追加する。
- 運用メモ: 障害対応手順 #5 [運用]一次切り分けチェックリストを更新する。
- UI改善: 一覧カードの整列 #6 [UI]長文タイトルでも高さが崩れないように調整。
表示件数:6 / 24
読み込み中...
解説
- 操作バーを下部に固定し、親指で押しやすい導線を作ります。
hx-indicatorと既存.LOADINGを組み合わせ、追加中を明示します。- レスポンスのOOB差し替えで、次offsetのボタンと表示件数を同期します。
次に読むオススメレシピ
参考リンク
このページの著者
経験:Webアプリ/業務システム
得意:PHP・JavaScript・MySQL・CSS
個人実績:フォーム生成基盤/クイズ学習プラットフォーム 等
詳しいプロフィールはこちら! もちもちみかんのプロフィール