本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100

ローディング

画像のローディング中はぼかした仮の画像を見せ、届いたものからピントが合って鮮明になる

デモ

操作:クリック。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

動作
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 に埋め込んでおくのがおすすめです。別のファイルにすると、仮の画像の読み込みを待つ時間ができてしまいます。

コード

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>

CSS
.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; }
}

JavaScript
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 }
  );
});

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

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