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

カード

スクロールでカードの画像がブラインドのように横のストライプで順に開く

デモ

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

仕様

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

しくみと調整

カードの画像が、横に細く分かれたストライプごとに、上から順に開いていきます。どのストライプも中央の線から上下へ広がるので、ブラインドの羽根が次々に返るように見えます。上へスクロールして戻せば閉じるので、何度でも見られます。説明の文は、ストライプが開ききるころに下から現れます。

元の画像を切り分けているわけではありません。高さ64px前後のストライプごとに同じ画像のコピーを入れ、画像の要素と同じ大きさのまま、ストライプの位置の分だけ上へずらして置いています。だから開ききると、継ぎ目なく1枚の写真につながります。ストライプの位置は要素に対する割合、コピーの高さは要素の高さ(100cqh)で決めているので、画面の幅が変わってもストライプを作り直さずに済みます。

開き方は clip-path の inset で、上下から半分ずつ切り落とした線の状態から広げていきます。0.7秒の power3.inOut を、0.06秒ずつずらして続けます。

つまずくのは継ぎ目です。境目がピクセルの半端な位置に来ると、背景色が細い線になって透けます。各ストライプを下へ1px重ねてこれを消し、開ききったら clip-path: none に戻して切り抜きそのものを外すことにしました。切り抜きを残したままだと、縁がにじんで画像の上端に背景色の線が見えるからです。

元の画像は透明にして残してあり、代替テキストはそのままスクリーンリーダーに届きます。ストライプを細くすると羽根が増えて細やかに、ずらす時間を延ばすと上から下へ波が伝わるように見えます。

コード

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