デモ
デモを読み込めませんでした。
仕様
- 用途
- ローディング
- 動作
- 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 を付け、骨組みは読み上げから外して、画面に出さない「読み込み中」の一文で待っていることを伝えます。
帯の形を、届く中身(アバター・名前・画像・本文)の並びに合わせて作ると、入れ替わったときに位置が跳ねません。動きを減らす設定では、光を流さずに骨組みだけを見せます。一覧、フィード、プロフィールの読み込みに向いています。
コード
<!-- 一覧の地。カードは白い面なので、地の色の上に置く -->
<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>
.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; }
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"));