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

カード

スクロールでカードがぼかしからピントが合って現れる

デモ

操作:スクロール。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。 触っている間は、ホイールでデモの中がスクロールします。

仕様

用途
カード
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

画面に入ってきたカードは、ぼんやりした色の広がりから輪郭が浮かび上がり、レンズのピントが合っていくように画像とキャプションが現れます。隠している間は18pxのぼかしをかけて1.06倍に拡大しておき、そこから1.4秒の power2.out で元に戻します。

不透明度だけは別扱いにして、0.5秒で先に上げきるようにしました。ぼかしと同じ長さで上げると、ぼけている間のカードはほとんど透けていて、ピントが合っていく途中が見えないまま終わります。同時に画面へ入ったカードは0.12秒ずつ開始をずらすので、横に並んだカードが左から順にピントを結びます。

判定は ScrollTrigger ではなく、ブラウザの IntersectionObserver で行っています。見せるのは画面に入った最初の一度きりで、スクロールの位置を追い続ける必要がないからです。同じタイミングで入ったカードはひとつの通知にまとまって届くので、その並び順でずらしています。隠すのは不透明度だけなので、表示前のキャプションもスクリーンリーダーには読み上げられます。

CSS の filter によるぼかしは描画の負担が大きく、ページ全体や大きなセクションにまとめてかけるとスクロールがかくつきます。かけるのはカードのような小さな単位にとどめ、ピントが合ったら clearProps で取り除いて、blur(0px) のまま残さないようにしています。ぼかしを強く、拡大を大きくするほどピントが合う動きははっきりし、輪郭のくっきりした写真や模様のある画像ほど、晴れていく様子がよくわかります。

コード

HTML
<section class="focus" aria-labelledby="focus-title">
  <div class="focus__head">
    <h2 class="focus__title" id="focus-title">ピントが合う瞬間。</h2>
    <p class="focus__lead">ここに説明文が入ります。</p>
  </div>

  <div class="focus__grid">
    <!-- ぼかすのはカード全体(画像とキャプション)。枚数が増えても、つなぎ方は変わらない -->
    <figure class="blur-in">
      <img src="/img/study-rings.jpg" alt="同心円の輪を重ねた版画" width="800" height="1000">
      <figcaption><span>01 — 波紋</span><span>シルクスクリーン</span></figcaption>
    </figure>
    <figure class="blur-in">
      <img src="/img/study-stripes.jpg" alt="斜めの縞を刷った版画" width="800" height="1000">
      <figcaption><span>02 — 斜めの縞</span><span>リソグラフ</span></figcaption>
    </figure>
    <figure class="blur-in">
      <img src="/img/study-dots.jpg" alt="青灰色の地に点を並べた版画" width="800" height="1000">
      <figcaption><span>03 — 水玉</span><span>シルクスクリーン</span></figcaption>
    </figure>
    <figure class="blur-in">
      <img src="/img/study-checks.jpg" alt="黄土色の市松模様を刷った版画" width="800" height="1000">
      <figcaption><span>04 — 市松</span><span>木版画</span></figcaption>
    </figure>
    <figure class="blur-in">
      <img src="/img/study-horizon.jpg" alt="地平線から昇る半円を描いた版画" width="800" height="1000">
      <figcaption><span>05 — 日の出</span><span>リソグラフ</span></figcaption>
    </figure>
    <figure class="blur-in">
      <img src="/img/study-split.jpg" alt="藤色とくすんだ桃色を斜めに塗り分けた版画" width="800" height="1000">
      <figcaption><span>06 — 対角</span><span>木版画</span></figcaption>
    </figure>
  </div>
</section>

CSS
.focus {
  --bg: #111111;
  --ink: #eeece6;
  --mute-small: #a8a6a0;
  --surface: #262626; /* 画像が届くまでの背景色 */
  padding: 96px 6vw;
  background: var(--bg);
  color: var(--ink);
}

/* 見出しとリード文は、最後の行の文字の並びでそろえる */
.focus__head {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: last baseline;
  gap: 24px 48px;
  margin-bottom: 64px;
}

.focus__title {
  margin: 0;
  font-size: clamp(36px, 5vw, 64px);
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
}

.focus__lead {
  margin: 0;
  font-size: 18px;
  line-height: 1.8;
  letter-spacing: 0.04em;
}

.focus__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 48px 32px;
  align-items: start;
}

/* 列ごとに下げて段差をつける */
.blur-in { margin: 0; }
.blur-in:nth-child(3n + 2) { margin-top: 72px; }
.blur-in:nth-child(3n) { margin-top: 32px; }

.blur-in img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  border-radius: 4px;
  background: var(--surface);
}

.blur-in figcaption {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  margin-top: 16px;
  font-size: 14px;
  line-height: 1.4;
  letter-spacing: 0.04em;
  color: var(--mute-small);
  font-variant-numeric: tabular-nums;
}

/* 狭い画面では 2 列、さらに狭ければ 1 列にする */
@media (max-width: 860px) {
  .focus__grid { grid-template-columns: repeat(2, 1fr); gap: 40px 24px; }
  .blur-in:nth-child(n) { margin-top: 0; }
  .blur-in:nth-child(even) { margin-top: 48px; }
}

@media (max-width: 520px) {
  .focus__grid { grid-template-columns: 1fr; }
  .blur-in:nth-child(n) { margin-top: 0; }
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const BLUR = 18; // 最初のぼかし(px)
const STAGGER = 0.12; // 同じときに画面へ入ったカードどうしの間隔(秒)

// カード 1 枚の現れ方。不透明度は 0.5 秒で先に上げきり、ぼかしと拡大は 1.4 秒かけて晴らす(ぼけた途中が見えるように)
function focusIn(card) {
  return gsap
    .timeline({ paused: true })
    .fromTo(card, { opacity: 0 }, { opacity: 1, duration: 0.5, ease: "power1.out" })
    .fromTo(
      card,
      { filter: `blur(${BLUR}px)`, scale: 1.06 },
      // 終わったら filter を外す。ぼかしは描画が重いので、blur(0px) のまま残さない
      { filter: "blur(0px)", scale: 1, duration: 1.4, ease: "power2.out", clearProps: "filter" },
      0
    );
}

document.querySelectorAll(".focus__grid").forEach((grid) => {
  // 動きを減らす設定では何もしない(カードは最初から表示されている)
  if (reduceMotion) return;
  // 隠すのは opacity だけ。表示される前のキャプションも、スクリーンリーダーには届く
  const reveals = new Map([...grid.querySelectorAll(".blur-in")].map((card) => [card, focusIn(card)]));

  // 表示するのは 1 回だけなので、スクロールを追い続ける ScrollTrigger は要らない。
  // 下を 15% 縮めた画面にカードが入ったら(上端が下から 15% の線を越えたら)コールバックが呼ばれる。
  // 同じときに越えたカードは 1 回のコールバックにまとまって届くので、その順番でずらして表示する
  const io = new IntersectionObserver(
    (entries) => {
      entries
        .filter((entry) => entry.isIntersecting)
        .forEach((entry, i) => {
          io.unobserve(entry.target);
          gsap.delayedCall(i * STAGGER, () => reveals.get(entry.target).play());
        });
    },
    { rootMargin: "0px 0px -15% 0px" }
  );
  reveals.forEach((_, card) => io.observe(card));
});

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

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