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

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

デモ

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

仕様

動作
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 に埋め込むと、本物より先に必ず出ます。ぼかしを強くすると色の雰囲気だけが残り、弱くすると粗い画素が見えてきます。動きを減らす設定では、焦点を合わせる動きを省き、届いた時点で切り替えます。記事の見出し画像、作品一覧、商品のギャラリーのように、大きな写真が並ぶページに向いています。

コード

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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。