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

スクロールでカードの画像が横帯(ブラインド)で順に開く

デモ

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

仕様

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

しくみと調整

画像を横の帯(高さ 64px 前後)に分け、帯ごとに中央の線から上下へ開いて見せます。どの帯にも同じ画像の写しを入れ、枠と同じ大きさのまま、帯の位置の分だけ上へずらして置くので、開ききると継ぎ目のない 1 枚に見えます。帯の位置は枠に対する割合で決め、写しの高さは枠の高さ(100cqh)にしてあるので、画面の幅が変わっても帯を作り直さずに済みます。

開き方は clip-path の inset で、上下から 50% ずつ切った線の状態から、0.7 秒かけて 0% まで広げます(power3.inOut)。上の帯から 0.06 秒ずつずらして続けると、ブラインドの羽が順にめくれるように見えます。枠の上端が画面の高さの 85% を越えたら開き、上へ戻して線より下へ外れたら閉じるので、何度でも見られます。説明の文は、帯が開ききるころに下から出します。

帯の境目が半端な位置に来ると、境目に地の色が細く透けます。帯を下へ 1px 重ねておくと消えます。開ききったあとも切り抜きを残していると、枠が半端な位置にあるときに縁がにじみ、画像の上端に地の色の線が出るので、開ききったら clip-path: none に戻します。元の画像は透明にして枠に残すので、代替テキストは読み上げに届きます。

帯を細くするほど羽が増えて細やかになり、ずらす時間を長くするほど、上から下へ波が伝わるように見えます。1 回だけ開けばよいなら、ScrollTrigger に once を付けます。動きを減らす設定では帯に分けず、画像をそのまま見せます。記事や事例紹介、旅行のページのように、読み進めるたびに写真が現れる縦長のページに向いています。

コード

HTML
<article class="journal" aria-labelledby="journal-title">
  <div class="journal__intro">
    <div>
      <p class="journal__label">Journal — No.12</p>
      <h2 class="journal__title" id="journal-title">海辺の町をめぐる</h2>
    </div>
    <p class="journal__lead">ここに説明文が入ります。</p>
  </div>

  <!-- .blinds の画像を、JS が横の帯に分けて開く。--ratio は枠の縦横比 -->
  <figure class="blinds" style="--ratio: 21 / 9">
    <div class="blinds__frame">
      <img src="/images/harbour-dawn.jpg" alt="夜明けの港。淡い紅色の空の下、静かな海に小さな帆船が浮かぶ" width="2100" height="900">
    </div>
    <figcaption class="blinds__caption"><span>01 — 夜明けの港</span> <span>午前5時42分</span></figcaption>
  </figure>

  <div class="journal__row">
    <section class="journal__story">
      <p class="journal__label">Chapter 01</p>
      <h3 class="journal__heading">潮の引いた<br>夜明けの港</h3>
      <p>ここに説明文が入ります。</p>
    </section>
    <figure class="blinds" style="--ratio: 4 / 5">
      <div class="blinds__frame">
        <img src="/images/keepers-light.jpg" alt="青空の下、緑の岬に立つ紅白の縞の灯台" width="864" height="1080">
      </div>
      <figcaption class="blinds__caption"><span>02 — 岬の灯台</span> <span>午前7時10分</span></figcaption>
    </figure>
  </div>

  <div class="journal__row">
    <figure class="blinds" style="--ratio: 4 / 3">
      <div class="blinds__frame">
        <img src="/images/dunes.jpg" alt="朝の日を受けて、淡い砂色に重なる砂丘の稜線" width="1024" height="768">
      </div>
      <figcaption class="blinds__caption"><span>03 — 砂丘のはじまり</span> <span>午前9時25分</span></figcaption>
    </figure>
    <section class="journal__story">
      <p class="journal__label">Chapter 02</p>
      <h3 class="journal__heading">道が砂に<br>変わる場所</h3>
      <p>ここに説明文が入ります。</p>
    </section>
  </div>
</article>

CSS
.journal {
  --bg: #f2f1ed;
  --ink: #262626;
  --mute: #6b6b6b;
  --holder: rgb(38 38 38 / 0.12); /* 開く前の枠の地 */
  --strip: 64px; /* 帯の高さの目安。JS が枠の高さから、この前後になる本数を決める */
  /* 中身の幅は 1088px まで。狭い画面では左右に余白をとる */
  padding: 96px max(clamp(16px, 7.5vw, 96px), (100% - 1088px) / 2) 144px;
  background: var(--bg);
  color: var(--ink);
}

.journal__intro {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: last baseline;
  gap: 24px 48px;
  margin-bottom: 48px;
}

.journal__label {
  margin: 0;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--mute);
}

.journal__title {
  margin: 16px 0 0;
  font-size: clamp(32px, 5.5vw, 64px);
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
  word-break: auto-phrase;
}

.journal__lead,
.journal__story p:last-child { margin: 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }

/* 文と画像を左右に振る。1 つめの段は文が左、2 つめの段は画像が左 */
.journal__row { display: flex; justify-content: space-between; align-items: center; gap: 48px; margin-top: 96px; }
.journal__story,
.journal__row .blinds { flex: 0 1 432px; }
.journal__row .blinds:first-child { flex-basis: 512px; }

.journal__heading { margin: 16px 0 24px; font-size: clamp(32px, 4vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }

.blinds { margin: 0; }

/* 枠。帯の中の写しは、この枠の高さ(100cqh)で敷く */
.blinds__frame {
  position: relative;
  overflow: hidden;
  aspect-ratio: var(--ratio);
  border-radius: 4px;
  background: var(--holder);
  container-type: size;
}

.blinds__frame img { display: block; width: 100%; height: 100%; object-fit: cover; }

/* 帯に分けたあとは、元の画像を透明にして残す(alt は読み上げに届く) */
.blinds__frame.is-split > img { opacity: 0; }

/* 帯は枠に対する % で並べ、境目に地の色が透けないよう下へ 1px 重ねる */
.blinds__strip {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(100% * var(--i) / var(--count));
  height: calc(100% / var(--count) + 1px);
  overflow: hidden;
}

/* 帯の中の写しは枠と同じ大きさにし、帯の位置の分だけ上へずらす。並べると継ぎ目のない 1 枚に見える */
.blinds__strip img { position: absolute; left: 0; top: calc(-100cqh * var(--i) / var(--count)); height: 100cqh; }

.blinds__caption {
  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);
  font-variant-numeric: tabular-nums;
}

@media (max-width: 767px) {
  .journal__row { display: grid; gap: 32px; margin-top: 64px; }
  .journal__lead,
  .journal__story p:last-child { font-size: 16px; }
}

JavaScript
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

gsap.registerPlugin(ScrollTrigger);

const EACH = 0.06; // 隣の帯とのずれ(秒)
const OPEN = 0.7; // 1 本の帯が開く時間(秒)

// 枠の中に、画像の写しを入れた横の帯を並べる。位置は CSS が --i と --count から決める
function split(figure) {
  const frame = figure.querySelector(".blinds__frame");
  const img = frame.querySelector("img");
  const strip = parseFloat(getComputedStyle(frame).getPropertyValue("--strip"));
  const count = Math.max(3, Math.round(frame.offsetHeight / strip));
  const box = document.createElement("div");
  box.setAttribute("aria-hidden", "true"); // 写しは読み上げない(元の画像の alt が読まれる)
  for (let i = 0; i < count; i++) {
    const band = document.createElement("div");
    band.className = "blinds__strip";
    band.style.setProperty("--i", i);
    const copy = img.cloneNode();
    copy.alt = "";
    band.append(copy);
    box.append(band);
  }
  frame.style.setProperty("--count", count);
  frame.append(box);
  frame.classList.add("is-split");
  return [...box.children];
}

// 動きを減らす設定では帯に分けず、画像をそのまま見せる
if (!matchMedia("(prefers-reduced-motion: reduce)").matches) {
  document.querySelectorAll(".blinds").forEach((figure) => {
    const strips = split(figure);
    gsap
      .timeline({
        scrollTrigger: {
          trigger: figure,
          start: "top 85%", // 枠の上端が画面の高さの 85% を越えたら開く
          toggleActions: "play none none reverse", // 上へ戻して線より下へ外れたら閉じ、また開けるようにする
        },
      })
      // 帯ごとに中央の線から上下へ開き、上の帯から順に続ける
      .fromTo(
        strips,
        { clipPath: "inset(50% 0% 50% 0%)" },
        { clipPath: "inset(0% 0% 0% 0%)", duration: OPEN, ease: "power3.inOut", stagger: EACH }
      )
      // 開ききったら切り抜きを外す。枠が半端な位置にあると、切り抜きの縁がにじんで、画像の縁に地の色の細い線が出る
      .set(strips, { clipPath: "none" })
      // 開ききるころに説明を出す。読み上げには初めから届くよう、opacity だけで隠す
      .fromTo(
        figure.querySelector(".blinds__caption"),
        { opacity: 0, y: 10 },
        { opacity: 1, y: 0, duration: 0.6, ease: "power2.out" },
        OPEN * 0.5 + EACH * strips.length * 0.5
      );
  });
}

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

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