Reactのカードを読み込む間は、実際のカードと幅・画像比率・余白を揃えたスケルトンを置くと、読み込み後に内容の位置が変わりにくくなります。短い処理でも毎回スケルトンが点滅する場合は、表示だけを少し遅らせる方法があります。
すでに表示できている内容を更新する場面なら、古い内容を残すほうが読みやすいこともあります。この記事では、300ms遅らせるスケルトン、Suspenseのfallback、useTransitionで旧表示を保つ例を作ります。実行確認はReact 19.3.0で行っています。
初回の空き領域と、更新中の旧表示を分けて考える
| 読み込みの場面 | この例で選ぶ表示 | 意図 |
|---|---|---|
| まだカードの内容がない | 寸法を確保し、待ち時間が長いときだけスケルトンを出す | 読み込み後の位置を先に用意する |
| 表示済みカードから、別のカードへ切り替える | 可能なら旧カードを残し、「更新中」と伝える | 読みかけの内容を突然消さない |
| 小さなボタン操作の完了を待つ | ボタン内の短い状態表示など | カード全体を置き換える必要がない |
カードの形が分かっている場面にスケルトンを使います。読み込み状態に合わせてスピナーを出し分ける基本は、Reactでローディングスピナーを表示する方法を参照してください。
実カードとスケルトンで、同じレイアウトを使う
次のCards.jsxには、実カード、装飾用のスケルトン、状態を切り替える領域をまとめています。ここでのカードの色面は、画像領域のサイズを示すサンプルです。
import React from 'react';
import { useDelayedFallback } from './useDelayedFallback';
import './cards.css';
export function ProductCard({ title, description, tone = 'sea' }) {
return (
<article className="product-card" data-card="loaded">
<div className={`product-card__media tone-${tone}`} aria-hidden="true" />
<div className="product-card__body">
<h3 className="product-card__title">{title}</h3>
<p className="product-card__description">{description}</p>
</div>
</article>
);
}
export function CardSkeleton() {
return (
<div className="product-card" aria-hidden="true" data-card="skeleton">
<div className="product-card__media skeleton-block" />
<div className="product-card__body">
<div className="product-card__title skeleton-block" />
<div className="product-card__description skeleton-block" />
</div>
</div>
);
}
export function CardRegion({ loading = false, error = false, product }) {
const showSkeleton = useDelayedFallback(loading, 300);
return (
<div className="card-region">
<p className="card-status" role="status">{loading ? '読み込み中です' : ''}</p>
<div aria-busy={loading}>
{loading ? (
<div className={showSkeleton ? '' : 'skeleton-reserved'}>
<CardSkeleton />
</div>
) : error ? (
<p role="alert">読み込めませんでした。時間を置いて再度お試しください。</p>
) : product ? (
<ProductCard {...product} />
) : (
<p>表示するカードがありません。</p>
)}
</div>
</div>
);
}
実カードとスケルトンの両方が、product-card__mediaとproduct-card__bodyを使います。片方だけpaddingや画像比率を変えると、読み込み完了時に下の要素が動きます。
CardRegionへは、既存のデータ取得処理が持つ状態を渡します。例えば<CardRegion loading={isLoading} error={hasError} product={product} />という使い方です。通信が失敗したらloadingをfalseにし、errorをtrueにしてエラー表示へ移してください。
スタイルはcards.cssに保存します。タイトルと説明文は、この例ではそれぞれ2行分の高さを確保しています。
.product-card {
width: 100%;
max-width: 360px;
box-sizing: border-box;
overflow: hidden;
border: 1px solid #d6dce4;
border-radius: 12px;
background: white;
color: #17253a;
}
.product-card__media { width: 100%; aspect-ratio: 16 / 9; }
.tone-sea { background: linear-gradient(135deg, #93c5fd, #0e7490); }
.tone-mountain { background: linear-gradient(135deg, #bbf7d0, #166534); }
.product-card__body { display: grid; gap: 12px; padding: 16px; }
.product-card__title, .product-card__description {
margin: 0;
min-height: 3rem;
line-height: 1.5rem;
font-size: 1rem;
overflow-wrap: anywhere;
}
.card-status { min-height: 1.5rem; line-height: 1.5rem; margin: 0 0 8px; }
.skeleton-reserved { visibility: hidden; }
.skeleton-block { position: relative; overflow: hidden; background: #e2e8f0; }
.skeleton-block::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, #ffffffb3, transparent);
transform: translateX(-100%);
animation: card-shimmer 1.4s linear infinite;
}
@keyframes card-shimmer { to { transform: translateX(100%); } }
@media (prefers-reduced-motion: reduce) {
.skeleton-block::after { animation: none; }
}
実際の文章が3行以上になるなら、その高さは増えます。スケルトンだけ寸法を固定しても、あらゆる内容でレイアウトの変化をゼロにはできません。実データの文字量に合わせて余白と高さを調整してください。画像を使う場合も、表示前に比率が分かるようにしておきます。
動く灰色の帯は装飾なので、スケルトン全体をaria-hidden="true"にしています。読み込み中であることは別のrole="status"で伝え、内容が入る領域にaria-busyを付けました。「動きを減らす」設定では帯のアニメーションを停止し、形だけ残します。CSSの別の見た目を試したい場合は、CSSローディングアニメーション集も利用できます。
300ms経っても読み込み中なら、スケルトンを見せる
useDelayedFallback.jsは、読み込み中になってから約300ms待って表示を有効にするフックです。完了した場合やコンポーネントを外した場合は、予約したタイマーを解除します。
import { useEffect, useState } from 'react';
export function useDelayedFallback(active, delayMs = 300) {
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!active) {
setVisible(false);
return;
}
const timer = window.setTimeout(() => setVisible(true), delayMs);
return () => window.clearTimeout(timer);
}, [active, delayMs]);
return active && visible;
}
CardRegionはこの戻り値がfalseの間、skeleton-reservedを付けてvisibility: hiddenにしています。display: noneと違い、見えなくてもカードの領域は残ります。「読み込み中です」の文字はすぐに表示し、灰色の形だけを遅らせる設計です。
最後のactive && visibleにより、読み込みが完了した描画ではスケルトンをすぐ非表示にします。完了後に古いタイマーが動いて表示を戻さないよう、Effectのcleanupも必要です。
300msはこのサンプルで選んだ値です。短い待ち時間の点滅を減らせますが、301msで完了するような境界付近の処理まで、点滅を完全になくせるわけではありません。最低表示時間を設ける方法もありますが、その分だけ取得済みの内容を見せるのが遅れます。更新画面では、次に紹介する旧表示の維持も検討できます。
Suspenseは、Effect内のfetchを自動では待たない
Suspenseのfallbackにも、同じスケルトン領域を使えます。ただし、useEffectで実行したfetchをSuspenseで囲むだけでは、fallbackは表示されません。その場合は先ほどのように、取得処理が管理するloadingを渡します。
以下では、Suspenseが扱える読み込みの例としてReact.lazyによるコンポーネントのコード読み込みを使います。データ取得をSuspenseへ連携する方式は、利用するフレームワークなどに合わせてください。対応する読み込みの種類はReact公式のSuspenseリファレンスに記載されています。
動的に読み込むMountainCard.jsxを用意します。このファイル自身にfetch処理はありません。
import React from 'react';
import { CardRegion } from './Cards';
export default function MountainCard() {
return <CardRegion product={{ title: '山の散歩コース', description: '緑を眺めながら歩くコースの紹介です。', tone: 'mountain' }} />;
}
コードの読み込みが終わるまで、親の<Suspense fallback={<CardRegion loading />}>が代わりの領域を表示します。ここでもスケルトンの形が見えるまで300ms待つため、空白が詰まらないよう、fallbackと読み込み後の両方に同じCardRegionを使います。
useTransitionで、読み込み済みのカードを残す
次のTransitionCards.jsxは、海のカードを最初に表示し、ボタン操作で山のカードのコードを読み込みます。2つのボタンは、状態変更をTransitionにするかどうかだけを変えています。
import React, { lazy, Suspense, useState, useTransition } from 'react';
import { CardRegion } from './Cards';
const MountainCard = lazy(() => import('./MountainCard'));
export function TransitionCards() {
const [view, setView] = useState('sea');
const [isPending, startTransition] = useTransition();
return (
<section aria-label="Suspenseの例">
<button type="button" onClick={() => startTransition(() => setView('mountain'))}>
山のカードへ(旧表示を維持)
</button>
<button type="button" onClick={() => setView('mountain')}>
山のカードへ(置き換え)
</button>
<p className="card-status" role="status">{isPending ? '更新中です(現在のカードを表示)' : ''}</p>
<Suspense fallback={<CardRegion loading />}>
{view === 'sea' ? (
<CardRegion product={{ title: '海の散歩コース', description: '水辺を歩くコースの紹介です。' }} />
) : (
<MountainCard />
)}
</Suspense>
</section>
);
}
「旧表示を維持」では、山のカードのコードが揃うまで海のカードが表示されたままになります。isPendingがtrueの間は、いま見えている内容が更新中だと伝えます。「置き換え」では通常の状態変更を行うため、読み込みに時間がかかればfallbackへ切り替わります。
これは、すでに内容を表示したSuspense境界での更新を扱う例です。最初から何も表示されていない領域に、useTransitionが旧内容を作ってくれるわけではありません。公式の不要なローディング表示を避ける例でも、この違いを確認できます。
lazy()はコンポーネントの外側で宣言します。また、デモでは同じSuspense境界を保っています。切り替えるたびに境界のkeyまで変えると、別の境界として作り直され、旧表示を維持する前提が変わります。lazyの宣言位置についてはReact公式のlazyリファレンスも参照してください。
一度読み込んだモジュールは次の切り替えで速く表示されるため、動作確認ではページを開き直し、必要ならブラウザのNetworkパネルで通信を遅くします。通信の遅延を本番コードへ埋め込む必要はありません。
速い・遅い・失敗の3通りで表示を確認する
スケルトンは、遅い通信だけを見ていると問題を見落としやすい部分です。今回の実行用サンプルでは、表示状態を80ms・900msのタイマーで作る検証と、実際のlazy importを遅らせる検証を分けて行いました。タイマーの値は通信性能の測定結果ではありません。
| 確認する場面 | 期待する状態 |
|---|---|
| 80msで読み込み完了 | 灰色のスケルトンを見せずに内容へ切り替わり、後からタイマーが復活しない |
| 900msの待ち時間 | 300ms経過後にスケルトンが見え、完了したら実カードへ切り替わる |
| 取得失敗 | 読み込み中を終え、エラーを伝える |
| タイマーが残る間に領域を削除 | タイマーが解除される。再表示は新しい待ち時間から始まる |
| 動きを減らす設定 | 形は残り、shimmerは停止する |
| lazy importを待つ通常の更新 | fallbackが表示される |
| lazy importを待つTransition | 旧カードと更新中の表示が残る |
テストではReact StrictModeを有効にし、スケルトンと実カードの高さ・表示位置も比較しました。カードだけでなく、上に置いたステータス行のline-heightも揃える必要があります。文字があるときだけ行が高くなると、カード本体の寸法が同じでも位置がずれます。
最後に、読み込み中の表示が消えることだけでなく、実データ、空の結果、失敗のどれへ進んだかを確認しましょう。初回は領域を確保し、更新時は残せる内容を残す。その違いを先に決めると、スケルトンを置く範囲も絞れます。