本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ローディング

ローディング中のスケルトンのカードにきらめきが流れる

デモ

仕様

動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

中身が届くまで、カードの形をなぞった灰色の帯(スケルトン)を置き、その上を光が左から右へ流れます。帯ごとに光の要素を 1 本ずつ入れ、どの光も「カードの左端から同じ位置」に来るように動かすので、カード全体を 1 本の光が横切って見えます。

光は幅 220px の白いグラデーションで、1.2 秒かけて等速で横切り、0.3 秒あけて次の光を流します。スケルトンの光は、速さを変えずに流すのが定番です。動かすのは transform の x だけなので、背景の位置を動かすより描画が軽く済みます。

光の x は「カードの左端からの位置 − 帯の左端(offsetLeft)」で決めます。どの帯にも同じ値を渡すと、光が帯ごとにずれて走ります。左端はカードを基準に測るので、カードに position: relative を付けておきます。カードの幅は画面に合わせて変わるので、repeatRefresh で 1 周ごとに測り直します。

読み込みが終わったら呼ぶ口(done())を用意し、光を止めて、骨組みを中身に入れ替えます。終わりの口が無いと、中身が届いても光が流れ続けます。読み込み中はカードに aria-busy を付け、骨組みは読み上げから外して、画面に出さない「読み込み中」の一文で待っていることを伝えます。

帯の形を、届く中身(アバター・名前・画像・本文)の並びに合わせて作ると、入れ替わったときに位置が跳ねません。動きを減らす設定では、光を流さずに骨組みだけを見せます。一覧、フィード、プロフィールの読み込みに向いています。

コード

HTML
<!-- 一覧の地。カードは白い面なので、地の色の上に置く -->
<div class="feed">
  <!-- 読み込み中は aria-busy="true"。done() を呼ぶと外れる -->
  <article class="skeleton-card" aria-busy="true">
    <!-- 骨組み。中身が届くまでの飾りなので、読み上げから外す -->
    <div class="skeleton" aria-hidden="true">
      <div class="skeleton__head">
        <span class="skeleton__bone skeleton__bone--avatar"></span>
        <span class="skeleton__lines">
          <span class="skeleton__bone skeleton__bone--name"></span>
          <span class="skeleton__bone skeleton__bone--small" style="--width: 34%"></span>
        </span>
      </div>
      <span class="skeleton__bone skeleton__bone--image"></span>
      <span class="skeleton__lines">
        <span class="skeleton__bone skeleton__bone--small"></span>
        <span class="skeleton__bone skeleton__bone--small" style="--width: 90%"></span>
        <span class="skeleton__bone skeleton__bone--small" style="--width: 62%"></span>
      </span>
    </div>

    <!-- 画面には出さず、読み上げにだけ待っていることを伝える -->
    <p class="skeleton__status" role="status">読み込み中</p>

    <!-- 届いた中身を入れる所。done() で骨組みと入れ替える -->
    <div class="skeleton-card__body"></div>
  </article>
</div>

CSS
.feed {
  display: grid;
  justify-items: center;
  padding: 96px 24px;
  background: #f2f1ed;
}

.skeleton-card {
  --surface: #ffffff;                /* カードの面 */
  --bone: rgba(38, 38, 38, 0.12);    /* 骨組みの帯 */
  --shine: rgba(255, 255, 255, 0.95); /* 流れる光 */
  --band: 220px;                     /* 光の帯の幅 */
  /* 骨組みと中身を同じ升に重ね、入れ替えるあいだカードの高さを保つ */
  position: relative;
  display: grid;
  box-sizing: border-box;
  width: min(512px, 100%);
  padding: 24px;
  border-radius: 4px;
  background: var(--surface);
}

.skeleton,
.skeleton-card__body {
  grid-area: 1 / 1;
}

.skeleton { display: flex; flex-direction: column; gap: 24px; }
.skeleton__head { display: flex; align-items: center; gap: 16px; }
.skeleton__lines { display: flex; flex: 1; flex-direction: column; gap: 8px; }

/* 帯。光の帯をはみ出させないよう、overflow: hidden で切る */
.skeleton__bone {
  position: relative;
  display: block;
  overflow: hidden;
  width: var(--width, 100%);
  height: 16px;
  border-radius: 8px;
  background: var(--bone);
}

.skeleton__bone--avatar { flex: none; width: 64px; height: 64px; border-radius: 50%; }
.skeleton__bone--name { width: 55%; }
.skeleton__bone--small { height: 12px; border-radius: 6px; }
.skeleton__bone--image { height: 152px; border-radius: 4px; }

/* 光の帯。JS が帯ごとに 1 本ずつ入れて、カードの左端からの同じ位置へ動かす */
.skeleton__shine {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--band);
  height: 100%;
  background: linear-gradient(100deg, transparent 15%, var(--shine) 50%, transparent 85%);
}

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

/* 中身は、done() を呼ぶまで見せない */
.skeleton-card[aria-busy="true"] .skeleton-card__body { opacity: 0; }

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SWEEP = 1.2; // 光がカードを横切る時間(秒)。スケルトンの光は等速で流す
const REST = 0.3; // 次の光までの間(秒)

function createSkeleton(card) {
  const skeleton = card.querySelector(".skeleton");
  const status = card.querySelector(".skeleton__status");
  const body = card.querySelector(".skeleton-card__body");
  const bones = [...card.querySelectorAll(".skeleton__bone")];

  // 動きを減らす設定では光を流さず、骨組みだけを見せる
  let shimmer = null;
  if (!reduceMotion) {
    const shines = bones.map((bone) => {
      const shine = document.createElement("span");
      shine.className = "skeleton__shine";
      return bone.appendChild(shine);
    });
    // 帯ごとの光の x を「カードの左端からの同じ位置 − 帯の左端」にすると、全部の帯を 1 本の光が横切って見える。
    // offsetLeft はカード(position: relative)からの位置。周ごとに測り直すので、カードの幅が変わってもずれない
    shimmer = gsap.fromTo(
      shines,
      { x: (i, shine) => -shine.offsetWidth - bones[i].offsetLeft },
      {
        x: (i) => card.offsetWidth - bones[i].offsetLeft,
        duration: SWEEP,
        ease: "none",
        repeat: -1,
        repeatDelay: REST,
        repeatRefresh: true,
      }
    );
  }

  let finished = false;
  return {
    // 読み込みが終わったら呼ぶ(中身を .skeleton-card__body に入れてから)。光を止め、骨組みを中身に入れ替える
    done() {
      if (finished) return;
      finished = true;
      shimmer?.kill();
      card.setAttribute("aria-busy", "false");
      status.textContent = "読み込みました";
      gsap
        .timeline({ onComplete: () => skeleton.remove() })
        .to(skeleton, { opacity: 0, duration: 0.25, ease: "power1.out" })
        .fromTo(body, { opacity: 0 }, { opacity: 1, duration: 0.4, ease: "power1.out" }, 0.1)
        .timeScale(reduceMotion ? 20 : 1);
    },
  };
}

// 例: 届いた中身を .skeleton-card__body に入れてから skeleton.done() を呼ぶ
export const skeleton = createSkeleton(document.querySelector(".skeleton-card"));

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。