スケルトンローディング(Skeleton Loading)— shimmer / pulse

表示・インジケーター 初級

このコンポーネントについて

スケルトンローディングは、データ取得中にコンテンツのシルエットをグレーのブロックで表示するUIパターンです。スピナー(くるくる)と異なり「何が来るか」の形があらかじめ見えるため、ユーザーの体感待ち時間を短く感じさせる効果があります。

ダッシュボード・一覧ページ・フィードなど、非同期でデータを取得する業務アプリで広く使われます。CSSアニメーションのみで実装でき、外部ライブラリは不要です。

  • Pattern 1 — カード型 × shimmer — グラデーションが左から右へ流れる光沢表現。グリッドカードのローディングに最適
  • Pattern 2 — リスト型 × pulse — グレーブロックがふわっとフェードイン/アウトを繰り返す。行ベースのデータ一覧のローディングに最適
  • 1秒後にコンテンツ切り替え — 「読み込む」ボタンでスケルトン → 実コンテンツへフェードインで切り替わる
  • リセット対応 — リセットボタンでスケルトン初期状態に戻る。読み込み中のキャンセルにも対応

実装のポイント・注意点

shimmerは background: linear-gradient(90deg, ...)background-size: 200% 100% で広めに定義し、@keyframesbackground-position を右から左へ動かすことで光沢が流れる表現を作ります。background-size を 200% にしないとグラデーションがすぐに端まで届いて光沢効果が出ないため、この値が重要です。

pulseは opacity1 → 0.4 → 1 とループさせるだけのシンプルな実装です。各行の animation-delay を少しずつずらすと波が伝わるような自然な動きになります。

コンテンツの切り替えは hidden 属性の付け外しで実装しています。hiddendisplay: none を適用するため、外したときに要素が「再表示」され CSS の animation が最初から再生されます。これを利用してフェードインアニメーションを毎回再生しています。

「読み込む」ボタン押下後にリセットした場合、setTimeout のタイマーがまだ動いているとリセット後にコンテンツが表示されてしまいます。clearTimeout でタイマーをキャンセルすることでこの問題を防いでいます。

HTML・CSS・バニラJavaScriptのみで実装しており、フレームワーク不要でコピペすぐに動きます。

デモ

Pattern 1 — カード型(shimmer)

Pattern 2 — リスト型(pulse)

サンプルソース

3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js — 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>スケルトンローディング サンプル</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<!-- Pattern 1: カード型(shimmer)-->
<section class="demo-section">
  <h2 class="pattern-label">Pattern 1 — カード型(shimmer)</h2>

  <!-- スケルトン(ローディング中)-->
  <div class="card-grid" id="p1-skeleton">
    <div class="sk-card">
      <div class="sk-thumb shimmer"></div>
      <div class="sk-body">
        <div class="sk-line shimmer sk-title"></div>
        <div class="sk-line shimmer"></div>
        <div class="sk-line shimmer sk-short"></div>
      </div>
    </div>
    <div class="sk-card">
      <div class="sk-thumb shimmer"></div>
      <div class="sk-body">
        <div class="sk-line shimmer sk-title"></div>
        <div class="sk-line shimmer"></div>
        <div class="sk-line shimmer sk-short"></div>
      </div>
    </div>
    <div class="sk-card">
      <div class="sk-thumb shimmer"></div>
      <div class="sk-body">
        <div class="sk-line shimmer sk-title"></div>
        <div class="sk-line shimmer"></div>
        <div class="sk-line shimmer sk-short"></div>
      </div>
    </div>
  </div>

  <!-- コンテンツ(読み込み後)-->
  <div class="card-grid content" id="p1-content" hidden></div>

  <div class="controls">
    <button id="p1-load-btn">読み込む</button>
    <button id="p1-reset-btn">リセット</button>
  </div>
</section>

<!-- Pattern 2: リスト型(pulse)-->
<section class="demo-section">
  <h2 class="pattern-label">Pattern 2 — リスト型(pulse)</h2>

  <!-- スケルトン(ローディング中)-->
  <ul class="item-list" id="p2-skeleton">
    <li class="sk-item">
      <div class="sk-list-thumb pulse"></div>
      <div class="sk-body">
        <div class="sk-line pulse sk-title"></div>
        <div class="sk-line pulse"></div>
        <div class="sk-line pulse sk-short"></div>
      </div>
    </li>
    <li class="sk-item">
      <div class="sk-list-thumb pulse"></div>
      <div class="sk-body">
        <div class="sk-line pulse sk-title"></div>
        <div class="sk-line pulse"></div>
        <div class="sk-line pulse sk-short"></div>
      </div>
    </li>
    <li class="sk-item">
      <div class="sk-list-thumb pulse"></div>
      <div class="sk-body">
        <div class="sk-line pulse sk-title"></div>
        <div class="sk-line pulse"></div>
        <div class="sk-line pulse sk-short"></div>
      </div>
    </li>
    <li class="sk-item">
      <div class="sk-list-thumb pulse"></div>
      <div class="sk-body">
        <div class="sk-line pulse sk-title"></div>
        <div class="sk-line pulse"></div>
        <div class="sk-line pulse sk-short"></div>
      </div>
    </li>
    <li class="sk-item">
      <div class="sk-list-thumb pulse"></div>
      <div class="sk-body">
        <div class="sk-line pulse sk-title"></div>
        <div class="sk-line pulse"></div>
        <div class="sk-line pulse sk-short"></div>
      </div>
    </li>
  </ul>

  <!-- コンテンツ(読み込み後)-->
  <ul class="item-list content" id="p2-content" hidden></ul>

  <div class="controls">
    <button id="p2-load-btn">読み込む</button>
    <button id="p2-reset-btn">リセット</button>
  </div>
</section>

<script src="./script.js"></script>
</body>
</html>
/* ===== CSS 変数(色の調整はここで)===== */
:root {
  --sk-color:     #E8ECF0;
  --sk-highlight: #F4F6F9;
}

*, *::before, *::after { box-sizing: border-box; }
body { font-family: sans-serif; padding: 24px; background: #f0f2f5; }

/* ===== デモセクション ===== */
.demo-section {
  background: #fff;
  border: 1px solid #e2e8f0;
  border-radius: 12px;
  padding: 20px;
  margin-bottom: 24px;
  max-width: 640px;
}
.pattern-label {
  margin: 0 0 16px;
  font-size: 13px;
  font-weight: 700;
  color: #2B7FE8;
}

/* ===== shimmer ===== */
@keyframes shimmer {
  0%   { background-position: 200% 0; }
  100% { background-position: -200% 0; }
}
.shimmer {
  background: linear-gradient(
    90deg,
    var(--sk-color) 25%,
    var(--sk-highlight) 50%,
    var(--sk-color) 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s linear infinite;
}

/* ===== pulse ===== */
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50%       { opacity: 0.4; }
}
.pulse {
  background: var(--sk-color);
  animation: pulse 1.5s ease-in-out infinite;
}

/* ===== フェードイン(コンテンツ表示時)===== */
@keyframes fadeIn {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}
.content { animation: fadeIn 0.4s ease; }

/* ===== スケルトン共通 ===== */
.sk-line { height: 12px; border-radius: 4px; margin-bottom: 8px; }
.sk-line:last-child { margin-bottom: 0; }
.sk-title { height: 16px; width: 70%; }
.sk-short { width: 40%; }

/* ===== Pattern 1: カード型 ===== */
.card-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
  margin-bottom: 16px;
}
.sk-card { border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; }
.sk-thumb { height: 100px; border-radius: 6px; margin-bottom: 12px; }

/* カード実コンテンツ */
.real-card  { border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; }
.real-thumb {
  height: 100px; border-radius: 6px; margin-bottom: 10px; background: #DBEAFE;
  display: flex; align-items: center; justify-content: center; font-size: 36px;
}
.real-title { margin: 0 0 6px; font-size: 14px; font-weight: 700; color: #1a2332; }
.real-text  { margin: 0 0 8px; font-size: 12px; color: #5a6a7a; line-height: 1.5; }
.real-tag {
  display: inline-block; padding: 2px 8px;
  background: #E8F0FE; color: #2B7FE8;
  border-radius: 12px; font-size: 11px; font-weight: 600;
}

/* ===== Pattern 2: リスト型 ===== */
.item-list { list-style: none; padding: 0; margin: 0 0 16px; }
.sk-item, .real-item {
  display: flex; align-items: flex-start;
  gap: 12px; padding: 12px 0;
  border-bottom: 1px solid #f0f2f5;
}
.sk-item:last-child, .real-item:last-child { border-bottom: none; }
.sk-list-thumb { width: 60px; height: 60px; border-radius: 6px; flex-shrink: 0; }
.sk-body { flex: 1; }

/* リスト実コンテンツ */
.real-thumb-sq {
  width: 60px; height: 60px;
  border-radius: 6px; background: #FEF9C3; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center; font-size: 26px;
}
.real-item-body { flex: 1; }
.real-name  { margin: 0 0 4px; font-size: 14px; font-weight: 700; color: #1a2332; }
.real-model { margin: 0 0 4px; font-size: 12px; color: #5a6a7a; }
.real-price { margin: 0; font-size: 13px; font-weight: 700; color: #2B7FE8; }

/* ===== ボタン ===== */
.controls { display: flex; gap: 8px; justify-content: flex-end; }
.controls button {
  padding: 6px 16px; font-size: 13px;
  border-radius: 6px; cursor: pointer;
  font-family: sans-serif; transition: background 0.15s;
}
.controls button:first-child {
  background: #2B7FE8; color: #fff; border: none;
}
.controls button:first-child:hover     { background: #1a6fd4; }
.controls button:first-child:disabled  { background: #9AB8E8; cursor: not-allowed; }
.controls button:last-child {
  background: #fff; color: #5a6a7a; border: 1.5px solid #D0D7E0;
}
.controls button:last-child:hover { background: #F4F6F9; }

/* ===== レスポンシブ ===== */
@media (max-width: 480px) {
  .card-grid { grid-template-columns: 1fr; }
}
// ===== ダミーデータ =====
var cardItems = [
  { title: '月次レポート',   body: '2026年4月の売上サマリーです。前月比 +12% を達成しました。', tag: '売上',    emoji: '📊' },
  { title: 'タスク完了率',   body: '今週のスプリントは目標の 94% を達成。残タスクは3件です。',   tag: 'タスク',  emoji: '✅' },
  { title: 'ユーザー登録数', body: '今月の新規登録は 1,248 件。前月比 +8% で順調に推移中。',    tag: 'ユーザー', emoji: '👥' },
];
var listItems = [
  { name: '65型 4K有機ELテレビ',  model: 'Model: TV-4K-65',  price: '¥330,000', emoji: '📺' },
  { name: 'ドラム式洗濯乾燥機',   model: 'Model: WD-DR8500', price: '¥198,000', emoji: '🫧' },
  { name: 'ノンフロスト冷蔵庫',   model: 'Model: RF-NF600',  price: '¥145,000', emoji: '❄️' },
  { name: 'ロボット掃除機',       model: 'Model: RC-A300',   price: '¥89,800',  emoji: '🤖' },
  { name: '食器洗い乾燥機',       model: 'Model: DW-S550',   price: '¥76,000',  emoji: '🍽️' },
];

// リセット時にタイマーをキャンセルするため ID を保持する
var p1Timer = null;
var p2Timer = null;

// ===== Pattern 1: カード型(shimmer)=====

document.getElementById('p1-load-btn').addEventListener('click', function() {
  this.disabled = true;
  p1Timer = setTimeout(function() {
    var skeleton = document.getElementById('p1-skeleton');
    var content  = document.getElementById('p1-content');
    skeleton.setAttribute('hidden', '');
    renderCards(content);
    content.removeAttribute('hidden');
    p1Timer = null;
  }, 1000);
});

document.getElementById('p1-reset-btn').addEventListener('click', function() {
  if (p1Timer) { clearTimeout(p1Timer); p1Timer = null; }
  var skeleton = document.getElementById('p1-skeleton');
  var content  = document.getElementById('p1-content');
  content.setAttribute('hidden', '');
  content.innerHTML = '';
  skeleton.removeAttribute('hidden');
  document.getElementById('p1-load-btn').disabled = false;
});

// カードコンテンツを生成して container に追加する
function renderCards(container) {
  container.innerHTML = '';
  for (var i = 0; i < cardItems.length; i++) {
    var item  = cardItems[i];
    var card  = document.createElement('div');  card.className  = 'real-card';
    var thumb = document.createElement('div');  thumb.className = 'real-thumb'; thumb.textContent = item.emoji;
    var title = document.createElement('p');    title.className = 'real-title'; title.textContent = item.title;
    var text  = document.createElement('p');    text.className  = 'real-text';  text.textContent  = item.body;
    var tag   = document.createElement('span'); tag.className   = 'real-tag';   tag.textContent   = item.tag;
    card.appendChild(thumb);
    card.appendChild(title);
    card.appendChild(text);
    card.appendChild(tag);
    container.appendChild(card);
  }
}

// ===== Pattern 2: リスト型(pulse)=====

document.getElementById('p2-load-btn').addEventListener('click', function() {
  this.disabled = true;
  p2Timer = setTimeout(function() {
    var skeleton = document.getElementById('p2-skeleton');
    var content  = document.getElementById('p2-content');
    skeleton.setAttribute('hidden', '');
    renderList(content);
    content.removeAttribute('hidden');
    p2Timer = null;
  }, 1000);
});

document.getElementById('p2-reset-btn').addEventListener('click', function() {
  if (p2Timer) { clearTimeout(p2Timer); p2Timer = null; }
  var skeleton = document.getElementById('p2-skeleton');
  var content  = document.getElementById('p2-content');
  content.setAttribute('hidden', '');
  content.innerHTML = '';
  skeleton.removeAttribute('hidden');
  document.getElementById('p2-load-btn').disabled = false;
});

// リストコンテンツを生成して container に追加する
function renderList(container) {
  container.innerHTML = '';
  for (var i = 0; i < listItems.length; i++) {
    var item  = listItems[i];
    var li    = document.createElement('li');  li.className    = 'real-item';
    var thumb = document.createElement('div'); thumb.className = 'real-thumb-sq'; thumb.textContent = item.emoji;
    var body  = document.createElement('div'); body.className  = 'real-item-body';
    var name  = document.createElement('p');   name.className  = 'real-name';  name.textContent  = item.name;
    var model = document.createElement('p');   model.className = 'real-model'; model.textContent = item.model;
    var price = document.createElement('p');   price.className = 'real-price'; price.textContent = item.price;
    body.appendChild(name);
    body.appendChild(model);
    body.appendChild(price);
    li.appendChild(thumb);
    li.appendChild(body);
    container.appendChild(li);
  }
}

AI用プロンプト

各パターンのプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。カード枚数や色の変更など、要件を追記して使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

Pattern 1 — カード型(shimmer)

# スケルトンローディング(カード型・shimmer)作成依頼

## 概要
データ読み込み中にカード型のスケルトンスクリーンを表示し、読み込み完了後に実コンテンツへ切り替えるUIを作成してください。

## 要件
- 3枚のカードがグリッド表示される
- ローディング中はshimmerアニメーション(光沢が左から右に流れる)のスケルトンを表示
- 「読み込む」ボタン押下で1秒後にダミーコンテンツへ切り替わる
- 切り替え時はフェードインで表示する
- リセットボタンでスケルトン初期状態に戻る
- リセット時に読み込みが進行中であればタイマーをキャンセルする

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要(モバイルでは1カラム)

## 動作詳細
初期表示はスケルトン状態。「読み込む」ボタン押下後、ボタンをdisabledにしてsetTimeoutで1秒待機。その後スケルトンを非表示(hidden属性)にし、ダミーカード(タイトル・本文・タグ)をfadeInで表示する。コンテンツはcreateElement + textContentで組み立て、innerHTML + 変数の結合は使わない。

## shimmer実装の要点
background: linear-gradient(90deg, #E8ECF0 25%, #F4F6F9 50%, #E8ECF0 75%) を background-size: 200% 100% で定義し、@keyframesでbackground-positionを200% → -200%に動かす。

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。

Pattern 2 — リスト型(pulse)

# スケルトンローディング(リスト型・pulse)作成依頼

## 概要
データ読み込み中にリスト型のスケルトンスクリーンを表示し、読み込み完了後に実コンテンツへ切り替えるUIを作成してください。

## 要件
- 5行のリストが縦に並ぶ(左に商品画像ブロック、右に商品名・モデル番号・価格の3行)
- ローディング中はpulseアニメーション(グレーがふわっと点滅)のスケルトンを表示
- 「読み込む」ボタン押下で1秒後にダミーコンテンツへ切り替わる
- 切り替え時はフェードインで表示する
- リセットボタンでスケルトン初期状態に戻る
- リセット時に読み込みが進行中であればタイマーをキャンセルする

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要

## 動作詳細
初期表示はスケルトン状態。「読み込む」ボタン押下後、ボタンをdisabledにしてsetTimeoutで1秒待機。その後スケルトンを非表示(hidden属性)にし、ダミーリスト(商品名・モデル番号・価格)をfadeInで表示する。コンテンツはcreateElement + textContentで組み立て、innerHTML + 変数の結合は使わない。

## pulse実装の要点
@keyframesでopacity: 1 → 0.4 → 1をループ(1.5s ease-in-out infinite)。各行のanimation-delayを0.1sずつずらすと波が伝わるような自然な動きになる。

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。