Tips & Tricks (更新: 2026/6/7)

スケルトンスクリーンの実装:スピナーより速く“感じる”ローディングUIをReactで作る

スケルトンスクリーンがスピナーより体感が速い理由と、CLSを起こさない実寸合わせの作り方。Suspenseとの組み合わせ、CSSの書き方、使う場面/使わない場面を実コードで。

スケルトンスクリーンの実装:スピナーより速く“感じる”ローディングUIをReactで作る

「読み込み中」のくるくる(スピナー)を置いたのに、ユーザーから「画面が固まって見える」と言われたことがあります。

体感速度を測ってみたら、表示までの実時間は前と1ミリ秒も変わっていませんでした。変えたのは、くるくるをスケルトンスクリーンに差し替えただけ。なのに「速くなった」と言われる。最初は意味がわかりませんでした。

スケルトンスクリーンは、データが届く前に「ここに画像」「ここにタイトル」という薄い灰色の骨組みを先に見せるUIです。白紙で待たせる代わりに、完成後の画面の“席取り”をしておく。たったそれだけの話なのに、ローディングの印象がまるで変わります。

この記事では、なぜスピナーより速く感じるのか、レイアウトがガクッと動く事故(CLS)をどう防ぐか、ReactのSuspenseとどう組み合わせるか、CSSでどう作るか、そして使わないほうがいい場面まで、僕が実際に詰まったポイント込みで書きます。

この記事の要点

  • スケルトンスクリーンが速く“感じる”のは、待ち時間に「何がどこに出るか」という情報を与えるから。実時間は変わらなくても体感が縮む。
  • 最大の価値はCLS対策。骨組みを実コンテンツと同じ寸法で置けば、データ到着時にレイアウトが動かない。
  • 状態は5つ(前・中・成功・空・失敗)で設計する。灰色の長方形を出すだけだと空とエラーが抜ける。
  • Suspenseのfallbackにスケルトンを渡せる。ただし発火するのはlazyuse・対応フレームワークのみ。useEffect内のfetchでは発火しない。
  • 速い処理(〜300ms)には出さない。チラついて逆効果になる。

なぜスピナーより速く感じるのか

スピナーが伝える情報は1つだけです。「処理中」。それ以上は何もわかりません。終わりがいつかも、何が出てくるかも不明なまま、ただ回り続ける円を眺めることになります。

スケルトンスクリーンは違います。「画像がここ」「見出しが2行」「説明文がこのくらい」「タグが1つ」と、完成形の地図を先に渡します。人は先が見えると待てるんですね。電車の遅延表示が「あと3分」と出るだけで、同じ3分でもイライラが減るのと同じ理屈です。

ここで効いているのが「知覚パフォーマンス(perceived performance)」という考え方です。これは、実際の処理時間ではなくユーザーが感じる速さのこと。スケルトンは実時間を1秒も縮めませんが、知覚上は確実に速くなります。MDNも遅延の見せ方について知覚的パフォーマンスで解説していて、「進捗が見える待ち時間は短く感じる」という人間の性質が土台にあります。

スピナーとスケルトンの違いを整理すると、こうなります。

スピナースケルトンスクリーン
伝える情報「処理中」だけ何がどこに出るか
表示領域確保しない(あとでズレやすい)先に確保できる(ズレない)
体感速度待たされている感が強い完成が近い感じがする
向く場面一瞬で終わる/全画面の初期化カード一覧・記事・ダッシュボード
作る手間軽いやや重い(寸法合わせが要る)

スピナーが悪いわけではありません。一瞬で終わる送信ボタンや、画面全体の初期化にはスピナーで十分です。骨組みの形が決まっているコンテンツ領域でこそ、スケルトンが効きます。

CLSを起こさない=実寸に合わせる

スケルトンスクリーンの本当の価値は、見た目のオシャレさではありません。CLS(Cumulative Layout Shift)対策です。

CLSは、ページの要素があとからガクッと動く量を測る指標です。読み込み完了の瞬間に画像が降ってきてボタンが下にずれ、押すつもりのない広告を踏む——あの事故の数値版だと思ってください。GoogleのCore Web Vitalsの1つで、web.devのCumulative Layout Shiftでも詳しく扱われています。表示速度の全体像はClaude Codeでパフォーマンス最適化にまとめたので、合わせてどうぞ。

スケルトンがCLSを防げるのは、データ到着の前後で寸法が変わらないからです。逆に言うと、ここを外すとスケルトンは「CLSを増やす部品」に化けます。僕が最初にやらかしたのが、まさにこれでした。

骨組みを本文より小さく作ったせいで、データが入った瞬間にカードが下に伸び、レイアウトが大きくジャンプしたんです。スケルトンを置いたのにCLSが悪化しました。原因は単純で、骨組みの寸法を「だいたいこんなもん」で決めていたから。

実寸に合わせるコツは3つです。

  1. 画像枠は width / heightaspect-ratio で高さを固定する。読み込み後に画像が入っても枠が変わらない。
  2. テキストは行数で予約する。タイトルは2行相当、説明は2行相当、と完成形に近い行高で骨組みを置く。
  3. カード全体に min-height を決め、骨組みと実カードで同じ値にする。

この「実寸合わせ」は画像の遅延読み込みと考え方が地続きです。画像側の寸法予約は画像の遅延読み込みを安全に実装するで具体的に書いたので、スケルトンと両輪で入れると効きます。

状態は5つで設計する

スケルトンを「灰色の長方形を置くだけ」と捉えると、必ず抜けが出ます。実務で扱うべき状態は5つです。

  • 読み込み前:まだ取得を始めていない(初期表示)
  • 読み込み中:スケルトンを出す
  • 成功:実データを描く
  • 空状態:取得は成功したがデータがゼロ件
  • 失敗:APIエラーなど

特に抜けやすいのが空状態とエラーです。スケルトンの見た目だけ先に作ると、「該当0件」のときも骨組みが出続けたり、APIが落ちているのに「待てば出る」と誤解させたりします。データ取得時のエラーの扱いはReactのError Boundaryを安全に実装するも参考になります。

下の図は、1つのレイアウトの中で5状態をどう切り替えるかの全体像です。

flowchart LR
  L["読み込み中: スケルトン"] --> S["成功: 実データ"]
  S --> E["空状態: 0件メッセージ"]
  S --> X["失敗: エラー + 再試行"]
  L --> A["aria-busy / role=status"]
  L --> C["寸法を実コンテンツに合わせる"]

コピペで試せるReact実装

ここからは手を動かしましょう。Vite + React + TypeScript の src/App.tsx にそのまま貼れる最小構成です。実APIの代わりに setTimeout で遅延させ、読み込み・成功・空・失敗をボタンで切り替えられます。

このパターンは自分でローディング状態を持つやり方です(Suspenseは使いません。理由は次の章で説明します)。

import { useEffect, useState } from "react";
import "./skeleton-demo.css";

type Article = {
  id: number;
  title: string;
  description: string;
  tag: string;
};

type LoadState = "loading" | "success" | "empty" | "error";

const demoArticles: Article[] = [
  {
    id: 1,
    title: "スケルトンで安全なUI差分を作る",
    description: "既存コンポーネントを読んでから、カード一覧の読み込み体験だけを直す。",
    tag: "UX",
  },
  {
    id: 2,
    title: "CLSを増やさない画像枠の決め方",
    description: "画像・タイトル・説明の高さを先に予約し、到着時のズレを消す。",
    tag: "Performance",
  },
  {
    id: 3,
    title: "アクセシブルなローディング表示",
    description: "aria-busy と status を組み合わせ、待ち時間を静かに伝える。",
    tag: "A11y",
  },
];

// スケルトンの1行。実テキストと同じ行高で「席」を取る
function SkeletonLine({ width = "100%" }: { width?: string }) {
  return <span className="sk-line" style={{ width }} aria-hidden="true" />;
}

// 骨組みカード。実カードと同じ min-height・余白にそろえるのが要点
function ArticleCardSkeleton() {
  return (
    <article className="article-card is-skeleton" aria-hidden="true">
      <div className="sk-media" />
      <div className="article-card__body">
        <SkeletonLine width="46%" />
        <SkeletonLine />
        <SkeletonLine width="86%" />
        <SkeletonLine width="32%" />
      </div>
    </article>
  );
}

function ArticleCard({ article }: { article: Article }) {
  return (
    <article className="article-card">
      <div className="article-card__media">{article.tag}</div>
      <div className="article-card__body">
        <p className="article-card__tag">{article.tag}</p>
        <h2>{article.title}</h2>
        <p>{article.description}</p>
      </div>
    </article>
  );
}

export default function App() {
  const [state, setState] = useState<LoadState>("loading");
  const [articles, setArticles] = useState<Article[]>([]);

  useEffect(() => {
    const timer = window.setTimeout(() => {
      setArticles(demoArticles);
      setState("success");
    }, 1200);

    return () => window.clearTimeout(timer);
  }, []);

  // ボタンで各状態を再現する(動作確認用)
  const reloadAs = (nextState: LoadState) => {
    setState("loading");
    setArticles([]);

    window.setTimeout(() => {
      setArticles(nextState === "success" ? demoArticles : []);
      setState(nextState);
    }, 700);
  };

  return (
    <main className="demo-shell">
      <div className="demo-toolbar" aria-label="表示状態を切り替える">
        <button onClick={() => reloadAs("success")}>成功</button>
        <button onClick={() => reloadAs("empty")}>空状態</button>
        <button onClick={() => reloadAs("error")}>エラー</button>
      </div>

      <section
        aria-busy={state === "loading"}
        aria-describedby="article-list-status"
        className="article-grid"
      >
        <p id="article-list-status" className="sr-only" role="status">
          {state === "loading" ? "記事一覧を読み込んでいます" : "記事一覧の読み込みが完了しました"}
        </p>

        {state === "loading" &&
          Array.from({ length: 3 }).map((_, index) => (
            <ArticleCardSkeleton key={index} />
          ))}

        {state === "success" &&
          articles.map((article) => (
            <ArticleCard key={article.id} article={article} />
          ))}

        {state === "empty" && (
          <div className="state-panel">該当する記事はまだありません。</div>
        )}

        {state === "error" && (
          <div className="state-panel" role="alert">
            記事一覧を読み込めませんでした。時間をおいて再試行してください。
          </div>
        )}
      </section>
    </main>
  );
}

アクセシビリティで外しがちなのが、スケルトンを読み上げ対象にしすぎることです。「灰色の線が3本あります」と読み上げても、目の見えない人には何の意味もありません。上の例では骨組みに aria-hidden="true" を付け、リストの状態だけを role="status" で静かに知らせています。status ロールは緊急でない状態変化を伝える用途で、詳しくはMDNのARIA: status roleを見てください。読み上げ全般の進め方はアクセシビリティ対応の実践ワークフローにまとめています。

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

Reactを使うなら、<Suspense>fallback にスケルトンを渡す形が一番きれいです。

import { Suspense, lazy } from "react";

// 重いコンポーネントを分割読み込み
const ArticleList = lazy(() => import("./ArticleList"));

export default function Page() {
  return (
    <Suspense fallback={<ArticleListSkeleton />}>
      <ArticleList />
    </Suspense>
  );
}

ArticleList の読み込みが終わるまで、Reactが自動で fallback(=スケルトン)を表示し、準備ができたら中身に差し替えてくれます。自分で isLoading を持たなくていいぶん、書き間違いが減ります。

ただし、ここにハマりどころが1つあります。Suspenseが発火するのは限られたケースだけ、という点です。React公式のSuspenseに明記されています。

  • lazy によるコンポーネントの分割読み込み
  • use フックでキャッシュ済みPromiseを読むとき
  • Suspense対応のフレームワーク(Next.jsなど)でのデータ取得

逆に、useEffect の中や、イベントハンドラの中で fetch しても、Suspenseは発火しません。これを知らずに「useEffect で取得しているのに fallback が出ない」と僕は半日溶かしました。だから前章のデモは、あえて自分で状態を持つ素朴なやり方にしてあります。

判断はシンプルです。

  • データ取得を use か対応フレームワーク経由でやっている → Suspenseのfallbackにスケルトン
  • useEffect で取得している(既存コードに多い) → 前章のように自分で状態管理

use でのデータ取得は、TanStack Queryのようなライブラリと組むと現実的になります。query keyやキャッシュの設計はTanStack Queryのキャッシュ設計で詳しく書いたので、Suspense前提で組むなら先に読んでおくと迷いません。

CSSで寸法と動きを固定する

次のCSSを src/skeleton-demo.css として保存します。狙いは「光り方の美しさ」より「レイアウトが動かないこと」。骨組みと実カードで min-height・画像枠・余白をそろえ、シマー(光が流れるアニメーション)は控えめにします。

:root {
  color: #18212f;
  background: #f6f7f9;
  font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

button {
  min-height: 40px;
  border: 1px solid #b8c2d6;
  border-radius: 8px;
  background: #ffffff;
  color: #18212f;
  padding: 0 14px;
  font-weight: 700;
}

.demo-shell {
  width: min(1040px, calc(100% - 32px));
  margin: 40px auto;
}

.demo-toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 18px;
}

.article-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 16px;
}

/* 骨組みと実カードで同じ min-height にそろえる=CLSの肝 */
.article-card {
  min-height: 316px;
  overflow: hidden;
  border: 1px solid #d7deea;
  border-radius: 8px;
  background: #ffffff;
}

.article-card__media,
.sk-media {
  display: grid;
  min-height: 148px;
  place-items: center;
  background: #dfe7f3;
  color: #39506f;
  font-weight: 800;
}

.article-card__body {
  display: grid;
  gap: 10px;
  padding: 18px;
}

.article-card__tag {
  color: #3b6b4f;
  font-size: 0.875rem;
  font-weight: 800;
}

.article-card h2 {
  min-height: 56px;
  margin: 0;
  font-size: 1.16rem;
  line-height: 1.45;
}

.article-card p {
  margin: 0;
  line-height: 1.7;
}

/* シマー(光が流れる演出)。背景グラデーションを横に動かすだけ */
.sk-line,
.sk-media {
  border-radius: 8px;
  background: linear-gradient(90deg, #d9e0ea 25%, #edf1f7 37%, #d9e0ea 63%);
  background-size: 240% 100%;
  animation: skeleton-shimmer 1.4s ease-in-out infinite;
}

.sk-line {
  display: block;
  height: 16px;
}

.state-panel {
  min-height: 180px;
  display: grid;
  place-items: center;
  border: 1px solid #d7deea;
  border-radius: 8px;
  background: #ffffff;
  padding: 24px;
  text-align: center;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
}

@keyframes skeleton-shimmer {
  from {
    background-position: 120% 0;
  }

  to {
    background-position: -120% 0;
  }
}

/* 「動きを減らす」設定の人には、流れる演出を止める */
@media (prefers-reduced-motion: reduce) {
  .sk-line,
  .sk-media {
    animation: none;
    background: #d9e0ea;
  }
}

最後の @media (prefers-reduced-motion: reduce) は外さないでください。これは、ユーザーがOSやブラウザで「動きを減らしたい」と設定しているかをCSSで見る条件です。強いシマーは人によって体調に響くので、その場合はアニメを止めて静かな灰色にします。MDNのprefers-reduced-motionも参考に。

骨組みの幅をランダムにしすぎると、実データに切り替わったとき文章量の差が目立ちます。タイトル2行・説明2行のように、完成形に近い寸法を先に決めるほうが現場では安定します。スケルトンと実カードでCSSの土台がズレると別ページまで崩れることがあるので、CSSが崩れない設計の進め方も合わせて読むと事故が減ります。

使う場面 / 使わない場面

スケルトンは万能ではありません。出すべき場面と、出すと逆効果な場面があります。

使うとよい場面

画面何を骨組みにするか注意点
記事カード一覧サムネ、タイトル2行、説明、タグ画像高さを固定しカード高を変えない
ダッシュボードKPIカード、グラフ枠、履歴数字だけ先出ししない(誤解防止)
ECの商品一覧商品画像、商品名、価格、評価古い価格・在庫を見せない
管理画面のテーブルヘッダー、行、操作ボタン枠行数が激変するならページ送りも見直す

使わないほうがいい場面

  • 速い処理(〜300ms程度):一瞬でスケルトンが消えるとチラついて、かえって遅く感じます。「300msを超えたら出す」「初回だけ出す」のルールが効きます。
  • 再取得(リフレッシュ):すでにデータが見えている画面で全部スケルトンに戻すと、情報が消えて不安になります。既存データを薄く残すほうが親切です。
  • 送信ボタンなどの一点操作:ボタン内のスピナーや無効化で十分。骨組みを作る意味が薄いです。
  • 全画面の初期スプラッシュ:レイアウトの形が決まっていない段階では、骨組みの寸法を合わせようがありません。

僕がClaudeCodeLabの相談導線で試したときは、記事カードとCTAの高さを先に固定しただけで読み込み中の違和感がかなり減りました。一方で、本文より大きい骨組みを置いたら、読み込み完了時に逆方向にズレて余計に目立ちました。スケルトンは演出ではなく、完成画面の寸法を予約する部品だと考えると判断を誤りません。

よくある質問

Q. スケルトンスクリーンとスピナー、結局どちらを使えばいい? A. 骨組みの形が決まっているコンテンツ領域(カード一覧、記事、ダッシュボード)はスケルトン。一瞬で終わる操作や全画面の初期化はスピナーで十分です。両方を場面で使い分けるのが現実的です。

Q. スケルトンを出したのにCLSが悪化しました。なぜ? A. 骨組みの寸法が実コンテンツとズレているからです。データ到着時にカードが伸縮するとレイアウトが動きます。画像は高さ固定、テキストは行数で予約し、骨組みと実カードの min-height をそろえてください。

Q. useEffect でfetchしているのにSuspenseのfallbackが出ません。 A. それが正しい挙動です。Suspenseが発火するのは lazyuse・対応フレームワークのデータ取得のみで、useEffect 内のfetchでは発火しません。その場合はこの記事の前半のように自分で状態を持ってスケルトンを出し分けます。

Q. シマー(光が流れる演出)は必須ですか? A. 必須ではありません。静止した灰色でも体感の改善は得られます。むしろ prefers-reduced-motion の人にはアニメを止めるべきなので、最初から「止められる前提」で作るのが安全です。

Q. どのくらいの遅延からスケルトンを出すべき? A. 目安は300ms。それより速い処理に毎回出すとチラつきます。「一定時間を超えたら表示」「初回だけ表示」「再取得時は既存データを残す」のいずれかをルール化すると安定します。

実際に試した結果

スケルトンスクリーンで一番効いたのは、見た目のオシャレさではなく寸法の予約でした。記事カード一覧で「画像枠とタイトル高さを先に固定する」「骨組みはリスト単位で読み上げる」「動きを減らす設定でアニメを止める」の3つを入れたら、読み込み中の違和感とCLSが同時に下がりました。

逆に、シマーの見た目だけ先に作った回は、空状態とエラーが後回しになり、公開前レビューで毎回手戻りしました。だから今は順番を変えています。①実カードの寸法を確定 → ②同じ寸法で骨組みを作る → ③空・エラーを先に用意 → ④最後にシマー。この順だと事故りません。

スピナーを置くのは1分でできます。スケルトンは10分かかります。でも「速くなった」と言われるのは、いつも後者でした。実時間は変わらないのに、です。ローディングは“消す”ものではなく、“見せ方を設計する”もの——というのが、半日溶かして得た僕の結論です。

UI改善をチームの標準に落とし込みたいなら、Claude Code研修・導入相談で既存リポジトリ前提に改善順序とレビュー観点まで一緒に整理します。まず手元で固めたい人は教材一覧もどうぞ。

#スケルトンスクリーン #ローディングUI #React #Suspense #CLS
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。