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を使用してます。

このページの著者

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

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

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

得意:PHP・JavaScript・MySQL・CSS

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

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

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

もちもちみかん.comとは


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

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