デモ
デモを読み込めませんでした。
仕様
- 用途
- ローディング
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
画像の枠に、本物を 20px 幅ほどに縮めた仮の画像を引き伸ばし、ぼかして敷いておきます。本物の img は届くまで透明にしておき、load を受けたら、10px ぼかしたところから 0.7 秒で焦点を合わせながら重ねます。仮の画像は本物と同じ色の配置なので、灰色の枠から写真が急に出るより、ページの印象が崩れません。
仮の画像のぼかしは 16px で、枠の外へ 1.1 倍に広げて置きます。ぼかした縁は透けるので、広げずに置くと枠の内側に白いにじみが出ます。重ねる画像の透明度は前半で 1 にし、ぼかしだけを最後まで晴らすと、2 枚が二重に見える時間が短くなります。
「読み込み中」の札は、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 }
);
});