Documentation Next.js

はじめに

Webアプリケーションでデータを取得している間、ユーザーは何も表示されない白い画面や、回転するローディングスピナーを見続けることがあります。この待機時間はユーザー体験(UX)を大きく損なう原因となります。

スケルトンローディングは、この問題を解決する効果的なUI技術です。Facebook、YouTube、LinkedInなど多くの大手サービスで採用されており、データ取得中でもページの構造を視覚的に示すことで、ユーザーに「コンテンツがまもなく表示される」という安心感を与えます。

この記事では、Next.jsでスケルトンローディングを実装する方法を、基本概念から実践的なコード例まで詳しく解説します。

この記事で学べること

  • スケルトンローディングの基本概念とUXへの効果
  • Next.jsのdynamic importを使った実装方法
  • React Suspenseとの連携テクニック
  • 再利用可能なスケルトンコンポーネントの作成
  • App Routerでのloading.tsx活用法

スケルトンローディングとは

スケルトンローディング(Skeleton Loading)は、コンテンツが読み込まれる前に表示されるプレースホルダーUIのことです。「スケルトン(骨格)」という名前の通り、最終的に表示されるコンテンツの骨組みを模した形状を表示します。

従来のローディング表示との違い

表示方法特徴UXへの影響
空白画面何も表示しないユーザーはエラーと勘違いする可能性がある
スピナー回転するアイコン進捗が分からず待ち時間が長く感じる
スケルトンコンテンツの形状を表示構造を予測でき、待ち時間が短く感じる

スケルトンローディングの心理的効果

スケルトンローディングは知覚的パフォーマンスを向上させます。実際の読み込み時間は変わらなくても、ユーザーは以下の理由で待ち時間を短く感じます。

  1. 進行感の提示 - 何かが起きていることが視覚的に分かる
  2. レイアウトシフトの防止 - コンテンツ表示時の画面のガタつきが減る
  3. 期待感の形成 - 表示されるコンテンツの予測ができる

基本的なスケルトンコンポーネントの作成

まず、再利用可能なスケルトンコンポーネントを作成します。

シンプルなスケルトンコンポーネント

// components/Skeleton.tsx
import React from 'react';
import styles from './Skeleton.module.css';

interface SkeletonProps {
  /** スケルトンの幅(例: "100%", "200px") */
  width?: string;
  /** スケルトンの高さ(例: "20px", "100px") */
  height?: string;
  /** 角を丸くするかどうか(アバター画像用など) */
  circle?: boolean;
  /** 追加のクラス名 */
  className?: string;
}

export const Skeleton: React.FC<SkeletonProps> = ({
  width = '100%',
  height = '20px',
  circle = false,
  className = '',
}) => {
  return (
    <div
      className={`${styles.skeleton} ${className}`}
      style={{
        width,
        height,
        borderRadius: circle ? '50%' : '4px',
      }}
      // アクセシビリティ: スクリーンリーダーに読み込み中であることを伝える
      role="status"
      aria-label="読み込み中"
    />
  );
};

スケルトンのCSSスタイル

シマーエフェクト(輝きが流れるアニメーション)を追加して、読み込み中であることを視覚的に強調します。

/* components/Skeleton.module.css */
.skeleton {
  /* ベースの背景色 */
  background: linear-gradient(
    90deg,
    #e0e0e0 25%,
    #f0f0f0 50%,
    #e0e0e0 75%
  );
  background-size: 200% 100%;

  /* シマーアニメーション */
  animation: shimmer 1.5s infinite ease-in-out;
}

@keyframes shimmer {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}

/* ダークモード対応 */
@media (prefers-color-scheme: dark) {
  .skeleton {
    background: linear-gradient(
      90deg,
      #2a2a2a 25%,
      #3a3a3a 50%,
      #2a2a2a 75%
    );
  }
}

dynamic importでスケルトンを表示する

Next.jsのdynamic importを使用すると、コンポーネントの遅延読み込み中にスケルトンを表示できます。これは、重いコンポーネントをコード分割して初期読み込み時間を短縮する際に特に有効です。

基本的な使い方

// pages/dashboard.tsx
import dynamic from 'next/dynamic';
import { Skeleton } from '@/components/Skeleton';

// ダッシュボードチャートは重いため、遅延読み込みする
const DashboardChart = dynamic(
  () => import('@/components/DashboardChart'),
  {
    // 読み込み中に表示するコンポーネント
    loading: () => (
      <div className="chart-skeleton">
        <Skeleton width="100%" height="300px" />
        <div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
          <Skeleton width="80px" height="20px" />
          <Skeleton width="80px" height="20px" />
          <Skeleton width="80px" height="20px" />
        </div>
      </div>
    ),
    // サーバーサイドレンダリングを無効化(クライアントのみで実行)
    ssr: false,
  }
);

export default function DashboardPage() {
  return (
    <main>
      <h1>ダッシュボード</h1>
      {/* チャートコンポーネントが読み込まれるまでスケルトンを表示 */}
      <DashboardChart />
    </main>
  );
}

ssr: falseオプションについて

ssr: falseを指定すると、コンポーネントはサーバーサイドではレンダリングされず、クライアントサイドでのみ実行されます。以下のような場合に使用します。

  • window/document API を使用するコンポーネント - サーバーではブラウザAPIが使えないため
  • 重いライブラリに依存するコンポーネント - チャートライブラリなど、初期バンドルサイズを削減したい場合
  • ユーザー固有のデータを表示するコンポーネント - SSRのキャッシュと相性が悪い場合

React Suspenseとの連携

React 18以降では、Suspenseを使用してデータフェッチ中のローディング状態を宣言的に管理できます。Next.js 13以降のApp Routerでは、この機能が標準でサポートされています。

Suspenseの基本パターン

// app/blog/page.tsx
import { Suspense } from 'react';
import { BlogList } from '@/components/BlogList';
import { BlogListSkeleton } from '@/components/BlogListSkeleton';

export default function BlogPage() {
  return (
    <main>
      <h1>ブログ記事一覧</h1>

      {/* Suspenseでラップすると、子コンポーネントがデータを取得中の間、
          fallbackに指定したコンポーネントが表示される */}
      <Suspense fallback={<BlogListSkeleton />}>
        <BlogList />
      </Suspense>
    </main>
  );
}

ブログリスト用のスケルトンコンポーネント

// components/BlogListSkeleton.tsx
import { Skeleton } from './Skeleton';
import styles from './BlogList.module.css';

export function BlogListSkeleton() {
  // 3件分のスケルトンを表示
  return (
    <div className={styles.blogList}>
      {[1, 2, 3].map((index) => (
        <article key={index} className={styles.blogCard}>
          {/* サムネイル画像のスケルトン */}
          <Skeleton width="100%" height="200px" />

          {/* タイトルのスケルトン */}
          <div style={{ padding: '16px' }}>
            <Skeleton width="80%" height="24px" />

            {/* 日付のスケルトン */}
            <Skeleton
              width="120px"
              height="16px"
              className={styles.dateSkeleton}
            />

            {/* 本文プレビューのスケルトン(複数行) */}
            <Skeleton width="100%" height="16px" />
            <Skeleton width="100%" height="16px" />
            <Skeleton width="60%" height="16px" />
          </div>
        </article>
      ))}
    </div>
  );
}

データを取得するコンポーネント

// components/BlogList.tsx
import styles from './BlogList.module.css';

// データ取得関数(実際のAPIに置き換えてください)
async function fetchBlogPosts() {
  const res = await fetch('https://api.example.com/posts', {
    // Next.js 13以降のキャッシュ設定
    next: { revalidate: 60 }, // 60秒ごとに再検証
  });

  if (!res.ok) {
    throw new Error('記事の取得に失敗しました');
  }

  return res.json();
}

export async function BlogList() {
  // このコンポーネントがSuspense内にある場合、
  // データ取得中は自動的にfallbackが表示される
  const posts = await fetchBlogPosts();

  return (
    <div className={styles.blogList}>
      {posts.map((post: any) => (
        <article key={post.id} className={styles.blogCard}>
          <img src={post.thumbnail} alt={post.title} />
          <div style={{ padding: '16px' }}>
            <h2>{post.title}</h2>
            <time>{post.publishedAt}</time>
            <p>{post.excerpt}</p>
          </div>
        </article>
      ))}
    </div>
  );
}

App Routerのloading.tsxを活用する

Next.js 13以降のApp Routerでは、特別なファイルloading.tsxを使用することで、ページ全体のローディング状態を簡単に管理できます。

ディレクトリ構造

app/
├── layout.tsx
├── page.tsx
├── blog/
│   ├── page.tsx        # ブログ一覧ページ
│   ├── loading.tsx     # ブログ一覧のローディング表示
│   └── [slug]/
│       ├── page.tsx    # 記事詳細ページ
│       └── loading.tsx # 記事詳細のローディング表示
└── dashboard/
    ├── page.tsx
    └── loading.tsx

loading.tsxの例

// app/blog/loading.tsx
import { Skeleton } from '@/components/Skeleton';

export default function BlogLoading() {
  return (
    <div className="container">
      {/* ページタイトルのスケルトン */}
      <Skeleton width="300px" height="40px" />

      {/* フィルターバーのスケルトン */}
      <div style={{ display: 'flex', gap: '12px', margin: '24px 0' }}>
        <Skeleton width="100px" height="36px" />
        <Skeleton width="100px" height="36px" />
        <Skeleton width="100px" height="36px" />
      </div>

      {/* 記事カードのスケルトン(グリッドレイアウト) */}
      <div style={{
        display: 'grid',
        gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))',
        gap: '24px'
      }}>
        {[1, 2, 3, 4, 5, 6].map((i) => (
          <div key={i} style={{ border: '1px solid #eee', borderRadius: '8px' }}>
            <Skeleton width="100%" height="180px" />
            <div style={{ padding: '16px' }}>
              <Skeleton width="90%" height="20px" />
              <Skeleton width="60%" height="16px" className="mt-2" />
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

loading.tsxが自動的に動作する仕組み

loading.tsxファイルを配置すると、Next.jsは自動的に以下のような構造を生成します。

// Next.jsが内部的に生成するコード(概念的な表現)
<Suspense fallback={<Loading />}>
  <Page />
</Suspense>

これにより、ページコンポーネント内でデータを取得している間、loading.tsxのコンテンツが自動的に表示されます。

実践的なスケルトンパターン集

カードリストのスケルトン

// components/skeletons/CardListSkeleton.tsx
import { Skeleton } from '../Skeleton';

interface CardListSkeletonProps {
  /** 表示するカードの数 */
  count?: number;
  /** カードに画像を含むかどうか */
  withImage?: boolean;
}

export function CardListSkeleton({
  count = 3,
  withImage = true
}: CardListSkeletonProps) {
  return (
    <div className="card-list">
      {Array.from({ length: count }).map((_, index) => (
        <div key={index} className="card">
          {withImage && (
            <Skeleton width="100%" height="200px" />
          )}
          <div className="card-body">
            {/* タイトル */}
            <Skeleton width="85%" height="24px" />
            {/* サブタイトル */}
            <Skeleton width="60%" height="18px" />
            {/* 本文(3行) */}
            <div className="card-text">
              <Skeleton width="100%" height="14px" />
              <Skeleton width="100%" height="14px" />
              <Skeleton width="75%" height="14px" />
            </div>
          </div>
        </div>
      ))}
    </div>
  );
}

ユーザープロフィールのスケルトン

// components/skeletons/ProfileSkeleton.tsx
import { Skeleton } from '../Skeleton';

export function ProfileSkeleton() {
  return (
    <div className="profile">
      {/* アバター画像(円形) */}
      <Skeleton width="120px" height="120px" circle />

      <div className="profile-info">
        {/* ユーザー名 */}
        <Skeleton width="180px" height="28px" />

        {/* 肩書き・役職 */}
        <Skeleton width="140px" height="18px" />

        {/* 自己紹介文 */}
        <div className="profile-bio">
          <Skeleton width="100%" height="16px" />
          <Skeleton width="100%" height="16px" />
          <Skeleton width="80%" height="16px" />
        </div>

        {/* ソーシャルリンク */}
        <div className="social-links">
          <Skeleton width="32px" height="32px" circle />
          <Skeleton width="32px" height="32px" circle />
          <Skeleton width="32px" height="32px" circle />
        </div>
      </div>
    </div>
  );
}

テーブルのスケルトン

// components/skeletons/TableSkeleton.tsx
import { Skeleton } from '../Skeleton';

interface TableSkeletonProps {
  /** 行数 */
  rows?: number;
  /** 列数 */
  columns?: number;
}

export function TableSkeleton({ rows = 5, columns = 4 }: TableSkeletonProps) {
  return (
    <table className="table">
      {/* ヘッダー */}
      <thead>
        <tr>
          {Array.from({ length: columns }).map((_, i) => (
            <th key={i}>
              <Skeleton width="80%" height="20px" />
            </th>
          ))}
        </tr>
      </thead>

      {/* ボディ */}
      <tbody>
        {Array.from({ length: rows }).map((_, rowIndex) => (
          <tr key={rowIndex}>
            {Array.from({ length: columns }).map((_, colIndex) => (
              <td key={colIndex}>
                <Skeleton
                  width={colIndex === 0 ? '60%' : '80%'}
                  height="18px"
                />
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

スケルトン実装のベストプラクティス

1. 実際のコンテンツと同じ構造を維持する

スケルトンは、最終的に表示されるコンテンツと同じレイアウト構造を持つべきです。これにより、コンテンツが表示される際のレイアウトシフト(CLS: Cumulative Layout Shift)を防ぎます。

// 良い例: 実際のコンテンツと同じ構造
function ArticleSkeleton() {
  return (
    <article className="article"> {/* 同じクラス名を使用 */}
      <Skeleton width="100%" height="400px" /> {/* 画像と同じサイズ */}
      <h1><Skeleton width="80%" height="36px" /></h1>
      <p><Skeleton width="100%" height="20px" /></p>
    </article>
  );
}

2. アクセシビリティへの配慮

スクリーンリーダーのユーザーにも読み込み中であることを伝えましょう。

<div
  role="status"
  aria-label="コンテンツを読み込み中"
  aria-busy="true"
>
  <Skeleton />
</div>

3. 適切な数のスケルトンを表示する

表示するスケルトンの数は、実際に表示されるデータ量に近づけます。10件のデータを表示する予定なら、3件程度のスケルトンを表示するのが適切です。多すぎると期待と実際の差が大きくなります。

4. アニメーションはさりげなく

シマーアニメーションは目立ちすぎないようにします。アニメーションが激しいと、ユーザーの注意を逸らし、かえってストレスを与える可能性があります。

まとめ

スケルトンローディングは、ユーザー体験を大幅に向上させる効果的なUI技術です。Next.jsでは、以下の方法で簡単に実装できます。

手法用途特徴
dynamic import + loadingコンポーネントの遅延読み込みコード分割との相性が良い
Suspense + fallback非同期コンポーネントのラップ宣言的で柔軟性が高い
loading.tsxページ全体のローディングApp Router専用、設定不要

重要なポイントを振り返ります。

  • スケルトンは実際のコンテンツと同じ構造を維持する
  • アクセシビリティへの配慮を忘れない
  • アニメーションはさりげなく、パフォーマンスに影響しない程度に

これらのテクニックを活用して、ユーザーがストレスなくコンテンツを待てるアプリケーションを構築しましょう。

参考文献

円