htmx 逆引きレシピ
読み込み中スピナーを出すには?
公開日:
最終更新日:
管理画面の検索や保存は、通信が少し遅いだけで「固まった?」と不安にさせがちです。
そこで、通信中だけスピナーを表示し、「処理中であること」を明確に伝えます。
このページでは、検索と保存の両方で同じローダーを使い回し、体感速度を上げる実装パターンを作ります。
hx-indicator を軸に、遅い環境でも安心して使えるUIに仕上げます。
使用するhtmx属性
hx-get:入力中にGETで検索し、結果だけ差し替える(ライブ検索向き)hx-post:POSTで保存し、結果のHTMLを差し替える(保存/更新向き)hx-trigger:リクエスト発火条件を指定(例:input delay:300ms/submit)hx-target:返ってきたHTMLを差し替える先を指定(結果エリアに更新)hx-swap:差し替え方を指定(例:innerHTML)hx-indicator:通信中だけ表示するローダー要素を指定(検索/保存で共通利用)hx-sync:リクエストの競合を制御(例:this:replaceで古い結果の上書きを防ぐ)
利用シーン
- 「検索/保存中を見せる」:検索結果が返るまでの“待ち”を明確にして安心させたい
- 「体感速度UP」:実際の速度は同じでも、処理中表示で“速く感じるUI”にしたい
- 「遅い環境でも不安にさせない」:回線が弱い現場でも、操作が止まったように見せたくない
読み込み中スピナーを出す(検索+保存)
検索/保存中を見せるだけで、ユーザーの不安を減らし、体感速度も上がります。
このデモは 検索 と 保存 の両方で同じLOADERを使います。
PHP(メイン/_indicator.php)
<?php
// 型を厳密に扱う
declare(strict_types=1);
// セッションを開始する
session_start();
// HTMLとして返す
header('Content-Type: text/html; charset=UTF-8');
// HTMLエスケープ関数
function h(string $s): string {
// 特殊文字をエスケープする
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
// 初期データが無ければ作る
if (!isset($_SESSION['indicator_items']) || !is_array($_SESSION['indicator_items'])) {
// ダミーデータを作る
$_SESSION['indicator_items'] = [
['id' => 1101, 'title' => '申請:端末貸与', 'owner' => 'tanaka'],
['id' => 1102, 'title' => '申請:権限変更', 'owner' => 'sato'],
['id' => 1103, 'title' => '申請:VPN追加', 'owner' => 'suzuki'],
['id' => 1104, 'title' => '申請:アカウント追加', 'owner' => 'tanaka'],
['id' => 1105, 'title' => '申請:共有フォルダ作成', 'owner' => 'kato'],
];
}
?>
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>読み込み中スピナー | htmx</title>
<!-- htmx -->
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<style>
#LOADER{
display: flex;
align-items: center;
justify-content: center;
position: fixed;
inset: 0;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity .15s ease;
z-index: 9999;
}
/* htmx通信中だけ表示 */
#LOADER.htmx-request{
opacity: 1;
visibility: visible;
pointer-events: auto;
}
#LOADER::before{
content: "";
position: absolute;
inset: 0;
background: rgba(0,0,0,0.35);
}
#LOADER::after{
content: "";
position: relative;
z-index: 1;
width: 150px;
height: 150px;
background-image: url('data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2024%2024%22%20width%3D%2248%22%20height%3D%2248%22%20fill%3D%22%23ffffff%22%3E%20%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%220%22%3E%20%3Canimate%20attributeName%3D%22opacity%22%20values%3D%220%3B1%3B0%22%20keyTimes%3D%220%3B.05%3B1%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3Canimate%20attributeName%3D%22r%22%20from%3D%220%22%20to%3D%2212%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3C%2Fcircle%3E%20%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%220%22%3E%20%3Canimate%20attributeName%3D%22opacity%22%20values%3D%220%3B1%3B0%22%20keyTimes%3D%220%3B.05%3B1%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20begin%3D%22.3s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3Canimate%20attributeName%3D%22r%22%20from%3D%220%22%20to%3D%2212%22%20dur%3D%221s%22%20begin%3D%22.3s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3C%2Fcircle%3E%20%3Ccircle%20cx%3D%2212%22%20cy%3D%2212%22%20r%3D%220%22%3E%20%3Canimate%20attributeName%3D%22opacity%22%20values%3D%220%3B1%3B0%22%20keyTimes%3D%220%3B.05%3B1%22%20calcMode%3D%22linear%22%20dur%3D%221s%22%20begin%3D%22.6s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3Canimate%20attributeName%3D%22r%22%20from%3D%220%22%20to%3D%2212%22%20dur%3D%221s%22%20begin%3D%22.6s%22%20repeatCount%3D%22indefinite%22%2F%3E%20%3C%2Fcircle%3E%3C%2Fsvg%3E');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
</style>
</head>
<body>
<!-- 通信中だけ表示されるオーバーレイLOADER(hx-indicatorで指定) -->
<div id="LOADER" class="htmx-indicator" role="status" aria-live="polite" aria-label="読み込み中"></div>
<section class="SECTION">
<h1>読み込み中スピナーを出すには?</h1>
<p class="HTMX-NOTE">
検索/保存中にスピナーを出すだけで「今処理してる」が伝わり、体感速度が上がります。<br>
このデモは“遅い通信”を想定して、サーバー側でわざと少し待つようにしています。
</p>
<hr>
<!-- =========================
① 検索(ライブ検索)
========================= -->
<section class="SECTION">
<h2>検索(ライブ検索)</h2>
<form
id="DEMO_INDICATOR_SEARCH"
class="FORM"
method="get"
>
<label>
キーワード(タイトル/担当)
<input
type="search"
name="q"
maxlength="64"
placeholder="例:tanaka / VPN / 権限"
hx-get="/htmx/demo/_indicator_search.php"
hx-trigger="input changed delay:300ms, search"
hx-target="#DEMO_INDICATOR_RESULT"
hx-swap="innerHTML"
hx-indicator="#LOADER"
hx-sync="this:replace"
>
</label>
<p class="HTMX-NOTE">
入力中も即検索します(delayで連打を抑え、スピナーで安心感を出します)。
</p>
</form>
<div id="DEMO_INDICATOR_RESULT" class="CARD">
<p class="HTMX-NOTE">ここに検索結果が表示されます。</p>
</div>
</section>
<hr>
<!-- =========================
② 保存(POST)
========================= -->
<section class="SECTION">
<h2>保存(POST)</h2>
<form
id="DEMO_INDICATOR_SAVE"
class="FORM"
method="post"
hx-post="/htmx/demo/_indicator_save.php"
hx-target="#DEMO_INDICATOR_SAVE_RESULT"
hx-swap="innerHTML"
hx-indicator="#LOADER"
>
<label>
タイトル
<input type="text" name="title" value="" maxlength="64" placeholder="例:申請:メーリングリスト追加">
</label>
<label>
担当
<input type="text" name="owner" value="" maxlength="64" placeholder="例:tanaka">
</label>
<button class="BTN is-ok" type="submit">
<span class="BTN__SPINNER">⏳</span>
保存する
</button>
<p class="HTMX-NOTE">
保存中もスピナーで“処理中”を見せると、連打や不安を減らせます。
</p>
</form>
<div id="DEMO_INDICATOR_SAVE_RESULT" class="CARD">
<p class="HTMX-NOTE">保存結果がここに表示されます。</p>
</div>
</section>
</section>
</body>
</html>
PHP(検索/_indicator_search.php)
<?php
// 型を厳密に扱う
declare(strict_types=1);
// セッションを開始する
session_start();
// HTMLとして返す
header('Content-Type: text/html; charset=UTF-8');
// HTMLエスケープ関数
function h(string $s): string {
// 特殊文字をエスケープする
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
// 検索キーワードを受け取る
$q = (string)($_GET['q'] ?? '');
// 前後空白を除去する
$q = trim($q);
// ダミー的に通信が遅い想定で待つ(体感速度UP用)
usleep(800000);
// 一覧を取得する
$items = (array)($_SESSION['indicator_items'] ?? []);
// 結果配列を用意する
$hits = [];
// 1件ずつ見る
foreach ($items as $it) {
// タイトルを文字列化する
$title = (string)($it['title'] ?? '');
// 担当を文字列化する
$owner = (string)($it['owner'] ?? '');
// キーワードが空なら全件ヒット
if ($q === '') {
$hits[] = $it;
continue;
}
// タイトル部分一致を判定する
$hitTitle = (mb_stripos($title, $q) !== false);
// 担当部分一致を判定する
$hitOwner = (mb_stripos($owner, $q) !== false);
// どちらも当たらないならスキップ
if (!$hitTitle && !$hitOwner) continue;
// ヒットなので採用する
$hits[] = $it;
}
?>
<div class="CARD">
<p><strong>ヒット:</strong><?= h((string)count($hits)) ?> 件</p>
<?php if (count($hits) === 0): ?>
<p class="HTMX-NOTE">該当データがありません。</p>
<?php else: ?>
<ul class="HTMX-LIST">
<?php foreach ($hits as $it): ?>
<li>
<strong>#<?= h((string)$it['id']) ?></strong>
<?= h((string)$it['title']) ?>
(<?= h((string)$it['owner']) ?>)
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
PHP(保存/_indicator_save.php)
<?php
// 型を厳密に扱う
declare(strict_types=1);
// セッションを開始する
session_start();
// HTMLとして返す
header('Content-Type: text/html; charset=UTF-8');
// HTMLエスケープ関数
function h(string $s): string {
// 特殊文字をエスケープする
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
// タイトルを受け取る
$title = (string)($_POST['title'] ?? '');
// 担当を受け取る
$owner = (string)($_POST['owner'] ?? '');
// 前後空白を除去する
$title = trim($title);
// 前後空白を除去する
$owner = trim($owner);
// ダミー的に通信が遅い想定で待つ(不安軽減用)
usleep(900000);
// タイトルが空なら補正する
if ($title === '') $title = '申請:新規作成';
// 担当が空なら補正する
if ($owner === '') $owner = 'tanaka';
// 新しいIDを作る
$newId = random_int(1200, 99999);
// 一覧を取得する
$items = (array)($_SESSION['indicator_items'] ?? []);
// 先頭に追加する
array_unshift($items, [
'id' => $newId,
'title' => $title,
'owner' => $owner,
]);
// セッションに保存する
$_SESSION['indicator_items'] = $items;
?>
<div class="FORM-RESULT is-ok">
<strong>保存しました。</strong>
<div>ID:<?= h((string)$newId) ?></div>
<div>タイトル:<?= h($title) ?></div>
<div>担当:<?= h($owner) ?></div>
<p class="HTMX-NOTE">
通信が遅くても「処理中」が見えていれば、ユーザーは安心して待てます。
</p>
</div>
デモ
読み込み中スピナーを出す(検索+保存)
解説
HTMLでやっていること
- ローダー要素(
#LOADER)を1つ用意し、検索/保存の両方でhx-indicator="#LOADER"を指定します。 - ライブ検索は
keyupではなくinputを使い、IME/スマホでも安定して反応するようにします。 delay:300msを入れて、入力中にリクエストが連発しすぎないように抑制します。- 結果表示は
hx-targetで結果エリアだけ差し替え、画面全体を更新しない設計にします。
CSSでやっていること
- ローダーは初期状態を非表示にし、通信中だけ
.htmx-requestで表示します。 - 暗幕(モーダル)は
::before、スピナー本体は::afterに分け、背景指定でスピナーが消える事故を防ぎます。 - “中央表示+暗幕”にすることで、通信中に画面操作できないことが直感的に伝わります。
PHPでやっていること
- 検索API(GET)はキーワードでフィルタし、結果HTMLだけ返して差し替えます。
- 保存API(POST)はセッションに追加し、保存結果のHTMLを返して差し替えます。
- デモでは
usleep()で遅延を入れ、スピナーの価値が分かりやすいようにしています。
ポイント:検索も保存も「通信中表示」を共通化すると、UIが統一されて安心感が増します。
“処理中であること”が見えるだけで、体感速度と操作の信頼性が大きく改善します。
※デモでは、便宜的に自作のCSSを使用してます。
参考リンク
このページの著者
経験:Webアプリ/業務システム
得意:PHP・JavaScript・MySQL・CSS
個人実績:フォーム生成基盤/クイズ学習プラットフォーム 等
詳しいプロフィールはこちら! もちもちみかんのプロフィール