Reactで、データを読み込むあいだスケルトンを表示する方法

​​React​CSS,Playwright,Suspense,TypeScript,use,アクセシビリティ

データを読み込むあいだ、読み込み後の画面と同じ形の灰色の骨組み(スケルトン)を出すには、スケルトンのコンポーネントを作って Suspense の fallback に渡します。スケルトンの帯の高さを文字の行に合わせ、長さが変わる文章の高さを CSS で決めておくと、データが届いて中身に置き換わっても、下にある段落は 1px も動きません。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で本番用に変換し、Playwright で Chromium を操作して、要素の位置や高さを読み取りました。データの取得は、決めたミリ秒だけ待ってから固定のデータを返す検証用の関数で代わりにしています。検証の出力にある「骨組み」は、スケルトンのことです。Firefox と Safari、スマートフォンの実機、実際のスクリーンリーダーでは試していません。

「再読み込み」ボタンを2回押す。押すたびに3枚のカードが灰色のスケルトンに変わり、光が左から右へ流れたあと、名前と紹介文の入ったカードに置き換わる。再読み込みで置き換わった瞬間だけカードの枠がピンクになる
「再読み込み」を押したときの切り替わり(上の回数の表示、マウスの絵、押したときのボタンの色、再読み込みで一覧が戻った瞬間のピンクの枠は GIF 用に足したもの)

灰色の帯を1つ描く Skeleton コンポーネント

スケルトンの部品は、灰色の帯や丸を1つ描くだけのコンポーネントにします。大きさは、置き換わる中身に合わせて呼び出す側から渡します。

tsx
import type { CSSProperties } from 'react';

type SkeletonProps = {
  width?: CSSProperties['width'];
  height?: CSSProperties['height'];
  circle?: boolean;
};

// 灰色の帯や丸を1つ描く。大きさは、置き換わる中身に合わせて渡す
export function Skeleton({ width = '100%', height, circle = false }: SkeletonProps) {
  return (
    <span
      className="skeleton"
      style={{ width, height, borderRadius: circle ? '50%' : undefined }}
    />
  );
}

見た目は CSS で付けます。帯の背景に、灰色の中に少し明るい部分があるグラデーションを横に2倍の幅で敷き、background-position を動かして、光が流れるように見せています。

css
/* 骨組みの帯。文字の行と同じ行の中に、少し細い帯を置く */
.skeleton {
  display: inline-block;
  vertical-align: middle;
  height: 0.8em;
  border-radius: 4px;
  background: linear-gradient(90deg, #e3e8eb 25%, #f3f6f7 50%, #e3e8eb 75%) 0 0 / 200% 100%;
  animation: skeleton-shimmer 1.5s linear infinite;
}
@keyframes skeleton-shimmer {
  from { background-position: 100% 0; }
  to { background-position: -100% 0; }
}

/* 動きを減らす設定のときは、光が流れる動きを止める */
@media (prefers-reduced-motion: reduce) {
  .skeleton { animation: none; }
}

display: inline-block にしているのは、帯を文字と同じ「行」の中に置くためです。高さを 0.8em にして、上下の位置を vertical-align: middle で行の真ん中にそろえると、帯は行より少し細く描かれ、行の高さは文字のときと変わりません。

一覧と同じ形のスケルトンを作る

例として、ユーザーの名前と紹介文をカードで並べる一覧を読み込みます。データは use で Promise から読みます。

tsx
import { use } from 'react';
import type { User } from './users';

export function UserList({ usersPromise }: { usersPromise: Promise<User[]> }) {
  const users = use(usersPromise);
  return (
    <ul className="user-list">
      {users.map((user) => (
        <li key={user.id} className="card">
          <span className="avatar" aria-hidden="true">{user.name[0]}</span>
          <div className="card-text">
            <p className="name">{user.name}</p>
            <p className="bio">{user.bio}</p>
          </div>
        </li>
      ))}
    </ul>
  );
}

スケルトンは、この一覧と同じ要素と同じクラス名で組み立てます。違うのは、文字の代わりに Skeleton を置くところだけです。名前の行には短い帯を1本、2行の紹介文には帯を2本置きます。

tsx
import { Skeleton } from './Skeleton';

// UserList と同じ形のカードを、灰色の帯と丸で描く
export function UserListSkeleton({ count = 3 }: { count?: number }) {
  return (
    <div>
      <p role="status" className="visually-hidden">ユーザーの一覧を読み込み中です</p>
      <ul className="user-list" aria-hidden="true">
        {Array.from({ length: count }, (_, i) => (
          <li key={i} className="card">
            <Skeleton width={48} height={48} circle />
            <div className="card-text">
              <p className="name"><Skeleton width="30%" /></p>
              <p className="bio">
                <Skeleton />
                <Skeleton width="60%" />
              </p>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

紹介文の2本の帯は、1本目の幅が 100% なので、2本目は自然に次の行へ送られます。.card や .name、.bio のクラスを一覧と共有しているので、余白や文字の大きさを変えたときも、スケルトンが同じように変わります。

Suspense の fallback にスケルトンを渡す

スケルトンは、Suspense の fallback に渡します。UserList の中の use がデータを待っている間は fallback が表示され、データが届くと一覧に置き換わります。

次の App は検証用のページのものです。fallback には、ふだんは <UserListSkeleton /> が入っています(比べるために、URL の値で「読み込み中…」の文字だけに切り替えられるようにしています)。load は、検証用の fetchUsers で取得を始めて Promise を返す関数です。noFix は、後で紹介する、紹介文の高さを決める CSS を外して比べるための切り替えです。実際のアプリでは、fallback={<UserListSkeleton />} と書き、load の代わりにデータを取得する関数を呼びます。

tsx
function App() {
  const [usersPromise, setUsersPromise] = useState(load);
  return (
    <main className={noFix ? 'no-fix' : undefined}>
      <button type="button" onClick={() => setUsersPromise(load())}>
        再読み込み
      </button>
      <Suspense fallback={fallback}>
        <UserList usersPromise={usersPromise} />
      </Suspense>
      <p id="below">一覧の下にある段落です。</p>
    </main>
  );
}

「再読み込み」を押すと、新しい Promise に差し替わり、表示中の一覧がもう一度スケルトンに戻ります。検証環境で、取得に 800ms かかるようにして試したところ、押してから約 100ms の時点ではスケルトンの帯が 12 個あり、約 900ms の時点では 0 個になっていました。

text
押してから約100ms: 骨組みの帯 12個 / 丸いアイコン 3個(見えているもの 0個、一覧の ul の style 属性: display: none !important;)
押してから約900ms: 骨組みの帯 0個 / 丸いアイコン 3個(見えているもの 3個、一覧の ul の style 属性: )

押してから約 100ms の時点でも、一覧の丸いアイコンは DOM に 3 個残っていました。ただし、一覧の ul に display: none !important; が付いていて、見えているものは 0 個です。React は表示中の一覧を消さずに隠し、スケルトンを出していました。再読み込みの間も前の一覧を見せたいなら、公式リファレンスにある、表示済みの内容を隠さないようにする方法のとおり、更新を startTransition で包みます。

帯の高さを文字の行に合わせて、下の段落を動かさない

スケルトンを出す一番の理由は、データが届いたときに画面の配置が動かないようにすることです。読み込み中と読み込み後の画面を並べると、次のようになります。

上に読み込み中の画面、下に読み込み後の画面。どちらも「再読み込み」ボタンの下にカードが3枚あり、その下の「一覧の下にある段落です。」は同じ高さにある
読み込み中(上)と読み込み後(下)の画面。見出しは画像用に足したもの

検証環境で、fallback が「読み込み中…」という文字だけの場合と、スケルトンの場合を比べました。比べたのは、一覧の下にある段落の上端の位置と、カードの高さ、Chromium が記録したレイアウトのずれ(layout-shift)の件数です。取得には 1500ms かかるようにしています。

text
fallback=text
  読み込み中: 段落の上端 112px / カードの高さ []
  読み込み後: 段落の上端 376px / カードの高さ [92,92,92]
  layout-shift: 1件 合計 0.0181
fallback=skeleton
  読み込み中: 段落の上端 376px / カードの高さ [92,92,92]
  読み込み後: 段落の上端 376px / カードの高さ [92,92,92]
  layout-shift: 0件 合計 0.0000

文字だけの場合は、データが届いた瞬間に、下の段落が 112px から 376px へ 264px 下がりました。スケルトンの場合は、読み込み中も読み込み後も 376px で、layout-shift は記録されていません。カードの高さも、どちらも 92px でした。

高さがそろうのは、帯を文字の行の中に置いているからです。スケルトンの最初のカードで、行と帯の高さを測った結果が次のとおりです。

text
skeleton 高さ 48px
name 高さ 24px
skeleton 高さ 12.8px
bio 高さ 42px
skeleton 高さ 11.19px
skeleton 高さ 11.19px

名前の行(16px、行の高さ 1.5)は 24px で、その中の帯は 16px の 0.8 倍の 12.8px です。紹介文(14px、行の高さ 1.5)は 2 行で 42px、その中の帯は 11.19px です。帯は行より細いのに、行の高さは文字が入ったときと同じになっています。帯の高さを em で決めているので、文字の大きさを変えたときも、帯はそれに合わせて太さが変わります(文字の大きさを変えた場合は試していません)。

長さが変わる文章は、表示する行数を決めておく

スケルトンの紹介文は 2 行ぶんですが、実際の紹介文は 1 行で終わることもあります。そのままだと、短い紹介文のカードだけが低くなり、下の段落が上がります。そこで、紹介文はいつも 2 行ぶんの高さにし、長いときは 2 行で切ります。

css
/* 紹介文は2行ぶんの高さに固定する(短くても長くても高さが変わらない) */
.bio {
  min-height: 3em;
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
}

min-height: 3em は、行の高さ 1.5 の 2 行ぶんです。-webkit-line-clamp は、決めた行数で文章を切ります。display: -webkit-box と -webkit-box-orient: vertical と組み合わせたときだけ使えます。

最初のユーザーの紹介文を短く(1 行に)して、この CSS がある場合とない場合を比べました。

text
fallback=skeleton&short=1
  読み込み中: 段落の上端 376px / カードの高さ [92,92,92]
  読み込み後: 段落の上端 376px / カードの高さ [92,92,92]
  layout-shift: 0件 合計 0.0000
fallback=skeleton&short=1&fix=0
  読み込み中: 段落の上端 376px / カードの高さ [92,92,92]
  読み込み後: 段落の上端 358px / カードの高さ [74,92,92]
  layout-shift: 1件 合計 0.0011

CSS がない場合は、最初のカードが 92px から 74px に縮み、下の段落が 18px 上がりました。スケルトンの形を合わせても、中身の長さで高さが変わる部分が残っていると、配置は動きます。文章を途中で切りたくない場合は、min-height だけにして、長い文章が来たときに伸びることは受け入れる、という選び方もあります(この組み合わせは試していません)。

読み上げのツリーからは帯を外し、「読み込み中」の文だけを残す

灰色の帯は、目で見て「何かが来る」と分かるためのもので、スクリーンリーダーで読ませる内容はありません。そこで、帯を並べた ul には aria-hidden="true" を付け、代わりに、画面には見えない「ユーザーの一覧を読み込み中です」という文を role="status" で置いています。visually-hidden は、要素を 1px の大きさにして切り取り、画面から見えなくするクラスです。

css
.visually-hidden {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}

Playwright で、main の中のアクセシビリティツリーを書き出しました。

text
[読み込み中]
- main:
  - button "再読み込み"
  - status: ユーザーの一覧を読み込み中です
  - paragraph: 一覧の下にある段落です。
[読み込み後]
- main:
  - button "再読み込み"
  - list:
    - listitem:
      - paragraph: たま
      - paragraph: 日当たりのよい窓辺で、午後はずっと寝ています。夜になると急に走り回ります。
    - listitem:
      - paragraph: みけ
      - paragraph: 段ボール箱を見つけると、必ず中に入ります。新しい箱ほど好きです。
    - listitem:
      - paragraph: くろ
      - paragraph: 朝ごはんの時間の10分前から、台所の前で待っています。
  - paragraph: 一覧の下にある段落です。

読み込み中のツリーには、12 本の帯も、帯を並べたリストも出てこず、「読み込み中です」の文だけが残っています。読み込み後は、名前と紹介文のリストに置き換わっています。

ただし、この文がスクリーンリーダーで読み上げられるかは確かめていません。MDN のライブリージョンの解説には、支援技術が知らせるのはライブリージョンの中身が変わったときで、まず空のライブリージョンを作り、次に中身を変えるように、と書かれています。この記事の作りでは、role="status" の要素は中身の文と一緒に画面に入るので、読み上げられないことがありえます。確実に知らせたい場合は、role="status" の要素を Suspense の外に置いたままにして、中の文だけを切り替える作りが考えられます(この作りは試していません)。

動きを減らす設定では、光の動きを止める

MDN の prefers-reduced-motion の解説には、動きのあるアニメーションが、前庭運動障碍のある人に不快感を引き起こす可能性があると書かれています。CSS の最後で、prefers-reduced-motion が reduce のときは animation: none にしています。Playwright でこの設定を切り替え、帯の状態を読み取りました。背景の位置は、0.5 秒あけて 2 回読んでいます。

text
prefers-reduced-motion: no-preference → animation-name: skeleton-shimmer, animation-duration: 1.5s, 動いているアニメーション: 1件 / background-position 53.3419% 0px → -15.5515% 0px
prefers-reduced-motion: reduce → animation-name: none, animation-duration: 0s, 動いているアニメーション: 0件 / background-position 0px 0px → 0px 0px

動きを減らす設定では、アニメーションが 0 件になり、背景の位置も変わっていません。帯は灰色のまま止まって表示されます。

早く届いても、スケルトンは約 300 ミリ秒表示される

データがすぐに届く場合、スケルトンが一瞬だけ出て消える「ちらつき」が気になるかもしれません。ところが、検証環境で取得の待ち時間を 50ms、150ms、1500ms に変えて、毎フレーム時刻を記録すると、早く届いても一覧はすぐには出ませんでした(時刻はページを開いてからのミリ秒です)。

text
fallback=skeleton 待ち時間50ms: 骨組みを描画 93ms / データが届いた 134ms / 一覧を表示 398ms(骨組みから 305ms)
fallback=skeleton 待ち時間150ms: 骨組みを描画 95ms / データが届いた 234ms / 一覧を表示 395ms(骨組みから 300ms)
fallback=skeleton 待ち時間1500ms: 骨組みを描画 58ms / データが届いた 1552ms / 一覧を表示 1562ms(骨組みから 1504ms)

50ms で届いた場合も、一覧が出たのはスケルトンが出てから約 300ms 後でした。これは、Suspense のリファレンスの注意事項に書かれている、React が待っていた中身を表示するのは、前回表示してから数えて 300 ミリ秒に 1 回まで、という動きです。試した 50ms と 150ms では、スケルトンが一瞬で消えることはありませんでした。

一方で、「早く届いたらスケルトンを出さない」つもりで、スケルトンを 300ms 遅れて見せる CSS(opacity を 300ms 後に 0 から 1 にする)を付けて試すと、かえって消える直前に一瞬だけ見えました。

text
fallback=delayed 待ち時間50ms: 骨組みを描画 68ms / データが届いた 113ms / 一覧を表示 377ms(骨組みから 309ms) / 骨組みが見え始めた 360ms
fallback=delayed 待ち時間150ms: 骨組みを描画 66ms / データが届いた 210ms / 一覧を表示 367ms(骨組みから 301ms) / 骨組みが見え始めた 350ms

スケルトンが見え始めてから一覧が出るまでは、どちらも 17ms でした。同じ測定をもう1回したときは、33ms と 35ms でした。React が中身を出すまで約 300ms 待つ間に、遅らせたスケルトンがちょうど見え始めてしまうためです。Suspense の fallback にスケルトンを使うなら、出すのを遅らせるより、最初から中身と同じ形で出しておくほうが、切り替わりが落ち着いて見えます。

​Posted by 86