概要
フォーム送信中・データ再取得中にコンテンツ上へ半透明オーバーレイを重ね、スピナーを表示するパターンを実装する。スケルトンカードが「コンテンツを置き換える」のに対し、オーバーレイは「コンテンツを保持したまま操作を無効化する」用途に使う。
インストール
npm install tailwindcss
実装
ローディングオーバーレイコンポーネント
// components/LoadingOverlay.tsx
type Props = {
isLoading: boolean;
children: React.ReactNode;
};
export function LoadingOverlay({ isLoading, children }: Props) {
return (
<div className="relative">
{children}
{isLoading && (
<div
className="absolute inset-0 flex items-center justify-center rounded bg-white/70"
aria-live="polite"
aria-label="読み込み中"
>
<svg
className="h-8 w-8 animate-spin text-blue-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"
/>
</svg>
</div>
)}
</div>
);
}
フォーム送信時に使う
// components/SampleForm.tsx
"use client";
import { useState } from "react";
import { LoadingOverlay } from "./LoadingOverlay";
export function SampleForm() {
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await fetch("/api/samples", { method: "POST" });
} finally {
setIsSubmitting(false);
}
};
return (
<LoadingOverlay isLoading={isSubmitting}>
<form onSubmit={handleSubmit} className="space-y-4 rounded border p-4">
<input
type="text"
placeholder="タイトル"
disabled={isSubmitting}
className="w-full rounded border px-3 py-2 text-sm disabled:opacity-50"
/>
<button
type="submit"
disabled={isSubmitting}
className="rounded bg-blue-600 px-4 py-2 text-sm text-white disabled:opacity-50"
>
送信
</button>
</form>
</LoadingOverlay>
);
}
一覧更新時に使う
// components/SampleListWithOverlay.tsx
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { LoadingOverlay } from "./LoadingOverlay";
type Props = {
children: React.ReactNode;
};
export function SampleListWithOverlay({ children }: Props) {
const [isPending, startTransition] = useTransition();
const router = useRouter();
const handleRefresh = () => {
startTransition(() => {
router.refresh();
});
};
return (
<div>
<button
onClick={handleRefresh}
className="mb-4 rounded border px-3 py-1 text-sm hover:bg-gray-50"
>
更新
</button>
<LoadingOverlay isLoading={isPending}>
{children}
</LoadingOverlay>
</div>
);
}
全画面オーバーレイ(ページ遷移中)
// components/PageLoadingOverlay.tsx
type Props = {
isLoading: boolean;
};
export function PageLoadingOverlay({ isLoading }: Props) {
if (!isLoading) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-white/80"
aria-live="assertive"
aria-label="ページを読み込んでいます"
>
<svg
className="h-10 w-10 animate-spin text-blue-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
</div>
);
}
ポイント
relative+absolute inset-0の組み合わせでコンテンツ上に正確にオーバーレイを重ねる。relativeを忘れるとabsoluteが最寄りの positioned 祖先を基準にずれるbg-white/70で半透明にする。元のコンテンツが透けて見えることでユーザーが状態遷移を認識しやすい。完全に隠すより UX がよいaria-live="polite"をオーバーレイに付けることで、スクリーンリーダーが「読み込み中」と通知できる。ページ遷移など緊急性の高い場合はaria-live="assertive"を使う- フォームの
input/buttonにdisabled={isSubmitting}を付けて二重送信を防ぐ。オーバーレイだけでは操作がキャンセルされない - スケルトンカードとの使い分け: スケルトンは「初回データ取得中・コンテンツがまだない状態」、オーバーレイは「操作中・既存コンテンツの上で処理中」に使う
finallyブロックでsetIsSubmitting(false)を呼ぶことで、エラー発生時もオーバーレイが閉じる- 一覧更新では
useTransition+startTransition(() => router.refresh())を使いisPendingをオーバーレイに渡す。setTimeoutの固定待機と異なり、実際の Router 更新完了に連動してオーバーレイが閉じる