デモ
デモを読み込めませんでした。
仕様
- 用途
- ローディング
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
写真が届く前から、その場所には同じ色合いのぼやけた画像が表示されています。本物の画像を幅20pxほどに縮めて引き伸ばし、ぼかしたものです。本物の img は読み込みが終わるまで透明にしておき、load を受け取ったら、10pxのぼかしから0.7秒かけてピントを合わせるように重ねます。グレーの四角から写真が突然現れるのと違い、読み込みの前後で画面の色合いがほとんど変わりません。
本物の画像の透明度は前半のうちに上げきり、ぼかしだけを最後まで少しずつ晴らしていくことにしました。透明度とぼかしを同じ長さで変えると、仮の画像と本物が半透明で重なる時間が長くなり、二重にぶれて見えます。仮の画像のほうは、表示する範囲より1.1倍大きく置いています。ぼかした画像は縁が透けるので、ぴったりの大きさでは内側の縁に白いにじみが出ます。
「読み込み中」のラベルは、0.5秒を過ぎても届かない画像にだけ表示します。すぐ届く画像にまで表示すると、ラベルが一瞬点いて消えるだけになり、かえって落ち着きません。届いた画像は decode() を待ってから重ねるので、大きな画像でもアニメーションの描き始めで引っかかりません。キャッシュから届いて、最初から読み込み済みの画像(complete)は、何もせずそのまま見せます。
仮の画像のぼかしを強くすると色の雰囲気だけが残り、弱めると縮めた画像の粗いピクセルが見えてきます。仮の画像そのものは、数百バイトの JPEG を data URI にして HTML に埋め込んでおくのがおすすめです。別のファイルにすると、仮の画像の読み込みを待つ時間ができてしまいます。
コード
<!-- 仮の画像(--lqip)は、元の画像を 20px 幅ほどに縮めた JPEG(数百バイト)。
data URI にして HTML に埋め込むと、元の画像より先に必ず表示される。width と height で縦横比を先に決めておく -->
<div class="gallery">
<figure class="blur-up" style="--lqip: url('/images/harbor-lqip.jpg')">
<img class="blur-up__image" src="/images/harbor.jpg" alt="夕暮れの港。沈む太陽の光が水面に筋を引いている" width="1200" height="840" loading="lazy" decoding="async" />
<span class="blur-up__status" aria-hidden="true">読み込み中</span>
</figure>
<figure class="blur-up" style="--lqip: url('/images/dunes-lqip.jpg')">
<img class="blur-up__image" src="/images/dunes.jpg" alt="重なる砂丘の稜線と、白い太陽" width="1200" height="500" loading="lazy" decoding="async" />
<span class="blur-up__status" aria-hidden="true">読み込み中</span>
</figure>
<figure class="blur-up" style="--lqip: url('/images/pines-lqip.jpg')">
<img class="blur-up__image" src="/images/pines.jpg" alt="雪原に針葉樹が並ぶ冬の森" width="1200" height="500" loading="lazy" decoding="async" />
<span class="blur-up__status" aria-hidden="true">読み込み中</span>
</figure>
</div>
.gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
align-items: start;
gap: 16px;
padding: 96px 24px;
background: #f2f1ed;
}
.blur-up {
--blur: 16px; /* 仮の画像のぼかし */
--badge: rgba(17, 17, 17, 0.64); /* ラベルの背景色 */
position: relative;
margin: 0;
overflow: hidden; /* はみ出したぼかしの縁を切る */
border-radius: 4px;
background: rgba(38, 38, 38, 0.12); /* 仮の画像も無いときの背景色 */
}
/* 仮の画像。ぼかすと縁が透けるので、少し拡大して縁を要素の外へはみ出させる */
.blur-up::before {
content: "";
position: absolute;
inset: 0;
background: var(--lqip) center / cover no-repeat;
filter: blur(var(--blur));
transform: scale(1.1);
}
.blur-up__image {
position: relative;
display: block;
width: 100%;
height: auto;
}
/* 読み込み終わるまで(読み込めなかったときも)元の画像を透明にして、仮の画像を見せる。スクリプトが動かないときは、そのまま表示される */
.blur-up.is-loading .blur-up__image,
.blur-up.is-error .blur-up__image {
opacity: 0;
}
/* ラベルは画像の中央に置く */
.blur-up__status {
position: absolute;
inset: 0;
width: fit-content;
margin: auto;
display: flex;
align-items: center;
gap: 10px;
height: 40px;
box-sizing: border-box;
padding: 0 18px 0 14px;
border-radius: 999px;
background: var(--badge);
color: #fff;
font-size: 14px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.06em;
white-space: nowrap;
visibility: hidden;
opacity: 0;
}
/* 回転するスピナー */
.blur-up__status::before {
content: "";
width: 12px;
height: 12px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: blur-up-spin 0.8s linear infinite;
}
.blur-up.is-error .blur-up__status::before {
display: none;
}
@keyframes blur-up-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.blur-up__status::before { animation: none; }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const BADGE_DELAY = 0.5; // これより早く読み込めた画像には、「読み込み中」のラベルを表示しない(秒)
document.querySelectorAll(".blur-up").forEach((figure) => {
const image = figure.querySelector(".blur-up__image");
const status = figure.querySelector(".blur-up__status");
// 読み込めなかったときは仮の画像のまま、ラベルで知らせる
const fail = () => {
figure.classList.add("is-error");
figure.setAttribute("aria-busy", "false");
status.textContent = "読み込めませんでした";
gsap.set(status, { autoAlpha: 1 });
};
// スクリプトより先に読み込みが済んだ画像(キャッシュから読めた・もう失敗した)は、イベントを待たない
if (image.complete) {
if (image.naturalWidth === 0) fail();
return;
}
figure.classList.add("is-loading");
figure.setAttribute("aria-busy", "true");
const badge = gsap.to(status, { autoAlpha: 1, duration: 0.2, delay: BADGE_DELAY });
const reveal = () => {
badge.kill();
figure.setAttribute("aria-busy", "false");
figure.classList.remove("is-loading");
gsap.to(status, { autoAlpha: 0, duration: 0.2 });
if (reduceMotion) return; // 動きを減らす設定では、読み込めた時点で切り替える
// 透明度は前半で 1 にし、ぼかしだけを最後まで晴らす(2 枚が二重に見える時間を短くする)。
// ぼかしが消えたら filter を外す(blur(0px) を残すと描画が重くなる)
gsap
.timeline({ onComplete: () => gsap.set(image, { clearProps: "opacity,filter" }) })
.fromTo(image, { opacity: 0 }, { opacity: 1, duration: 0.35, ease: "power1.out" }, 0)
.fromTo(image, { filter: "blur(10px)" }, { filter: "blur(0px)", duration: 0.7, ease: "power2.out" }, 0);
};
// 読み込んですぐ表示すると、大きな画像は描画の始めでカクつく。decode() でデコードし終えてから表示する
image.addEventListener("load", () => image.decode().catch(() => {}).then(reveal), { once: true });
image.addEventListener(
"error",
() => {
badge.kill();
fail();
},
{ once: true }
);
});